OverviewFilesCommitsIssuesDocsReleasesPeers

blender-bridge / blender_addon / change_detector.py

819 lines · python ref:
"""Detect Blender scene changes and export to shared memory.

Uses depsgraph_update_post handler to detect transform and geometry changes,
accumulates them in memory, and flushes to SHM region 1 (Blender->Godot)
on timer tick to prevent rapid depsgraph fires from overwriting data.
"""

import struct

import numpy as np

from .shm_protocol import (
    LIGHT_POINT,
    LIGHT_SPOT,
    LIGHT_SUN,
    OBJ_CAMERA,
    OBJ_EMPTY,
    OBJ_LIGHT,
    OBJ_MESH,
    TRANSFORM_SIZE,
    UPDATE_DELETE,
    UPDATE_FULL,
    UPDATE_TRANSFORM,
    UPDATE_VERTEX,
    ShmRegion,
)

try:
    import bpy
    import mathutils
except ImportError:
    bpy = None
    mathutils = None


def _blender_type_to_obj_type(obj) -> int:
    """Map Blender object type to protocol ObjectType."""
    if obj.type == "MESH":
        return OBJ_MESH
    elif obj.type == "CAMERA":
        return OBJ_CAMERA
    elif obj.type in ("LIGHT", "LAMP"):
        return OBJ_LIGHT
    else:
        return OBJ_EMPTY


def _blender_light_sub_type(obj) -> int:
    """Map Blender light type to protocol LightSubType."""
    if obj.type not in ("LIGHT", "LAMP"):
        return 0
    light_type = obj.data.type
    if light_type == "SUN":
        return LIGHT_SUN
    elif light_type == "SPOT":
        return LIGHT_SPOT
    return LIGHT_POINT


class ChangeDetector:
    """Detects Blender scene changes and exports to SHM region 1.

    Changes are accumulated in memory during depsgraph_update_post callbacks,
    then flushed to SHM atomically when flush() is called from the timer.
    This prevents rapid depsgraph fires from overwriting UPDATE_FULL data
    with UPDATE_TRANSFORM before Godot reads it.
    """

    # Minimum bytes per mesh vertex (positions + normals + UVs + overhead)
    # 3 pos floats + 3 normal floats + 2 UV floats = 8 floats × 4 bytes = 32 bytes
    # plus count headers (5 × 4 = 20 bytes overhead per mesh) + name + transform
    _BYTES_PER_VERTEX = 32
    _MESH_OVERHEAD = 256  # name + transform + headers + material + alignment

    def __init__(self, shm: ShmRegion, max_objects: int = 256):
        self._shm = shm
        self._max_objects = max_objects
        self._sequence = 0
        # Map Blender object name -> object_id
        self._name_to_id: dict[str, int] = {}
        self._next_id = 1
        self._registered = False
        # Pending changes accumulated between flushes.
        # Key: object name, Value: (update_type, depsgraph ref for mesh extraction)
        self._pending: dict[str, int] = {}
        # Cache of depsgraph for mesh extraction during flush
        self._last_depsgraph = None
        # Feedback loop prevention: per-object suppression after applying
        # incoming Godot transforms. Only suppresses echoes for the specific
        # objects that Godot sent, allowing modifier-dependent updates through.
        # Dict: object_name → remaining ticks
        self._suppressed_objects: dict[str, int] = {}
        # Transform hash cache for jitter reduction
        self._transform_hashes: dict[str, int] = {}
        # Geometry hash cache: prevents re-flushing unchanged geometry
        # (geometry nodes fire is_updated_geometry every frame even when output is identical)
        self._geometry_hashes: dict[str, int] = {}
        # Dirty images: image names that were edited (texture paint, image editor)
        # Drained by the timer for re-baking via TileBaker
        self._dirty_images: set[str] = {}
        # Known texture image names (set by timer from baker._texture_ids)
        self._tracked_images: set[str] = set()
        # Shading update flag: set when any material parameter changes
        self._had_shading_update = False
        # Callback for notifying Godot of SHM resize: fn(new_size, new_region_size)
        self._resize_callback = None
        # Texture ID lookup: image_name → int tex_id (shared ref from TileBaker)
        self._texture_ids: dict[str, int] = {}

    def register(self):
        """Register the depsgraph handler."""
        if self._registered:
            return
        bpy.app.handlers.depsgraph_update_post.append(self._on_depsgraph_update)
        self._registered = True

    def unregister(self):
        """Unregister the depsgraph handler."""
        if not self._registered:
            return
        try:
            bpy.app.handlers.depsgraph_update_post.remove(self._on_depsgraph_update)
        except ValueError:
            pass
        self._registered = False

    def suppress(self, names: set[str], frames: int = 5):
        """Suppress depsgraph echoes for specific objects.

        Only the named objects are suppressed. Other objects (e.g. those
        with modifiers that depend on the moved object) still get their
        updates captured and flushed to Godot.

        Pre-caches transform and geometry hashes so that when suppression
        expires, the objects won't be falsely detected as "changed".
        """
        for name in names:
            self._suppressed_objects[name] = max(
                self._suppressed_objects.get(name, 0), frames
            )
            # Pre-cache hashes to prevent false detection after unsuppress
            obj = bpy.data.objects.get(name) if bpy else None
            if obj is not None:
                self._transform_hashes[name] = self._hash_transform(obj.matrix_world)
                if obj.type == "MESH" and obj.data is not None:
                    verts = obj.data.vertices
                    if len(verts) > 0:
                        coords = np.empty(len(verts) * 3, dtype=np.float32)
                        verts.foreach_get("co", coords)
                        self._geometry_hashes[name] = hash(coords.tobytes())

    def unsuppress(self):
        """Decrement suppression counters (called each timer tick)."""
        expired = []
        for name, ticks in self._suppressed_objects.items():
            if ticks <= 1:
                expired.append(name)
            else:
                self._suppressed_objects[name] = ticks - 1
        for name in expired:
            del self._suppressed_objects[name]

    def set_id_map(self, id_map: dict[int, str]):
        """Merge object ID mapping from scene importer (additive, not replace)."""
        for obj_id, name in id_map.items():
            if name not in self._name_to_id:
                self._name_to_id[name] = obj_id
        if self._name_to_id:
            self._next_id = max(self._name_to_id.values()) + 1

    def get_or_assign_id(self, name: str) -> int:
        """Get existing ID or assign a new one for a Blender object."""
        if name not in self._name_to_id:
            self._name_to_id[name] = self._next_id
            self._next_id += 1
        return self._name_to_id[name]

    def get_id_map(self) -> dict[int, str]:
        """Return the current ID map as {object_id: name} for syncing with SceneImporter."""
        return {obj_id: name for name, obj_id in self._name_to_id.items()}

    def set_tracked_images(self, image_names: set[str]):
        """Set which image names should trigger re-bake on edit."""
        self._tracked_images = image_names

    def drain_dirty_images(self) -> set[str]:
        """Return and clear the set of dirty image names."""
        dirty = self._dirty_images
        self._dirty_images = set()
        return dirty

    def had_shading_update(self) -> bool:
        """Return and clear the shading update flag (set when material parameters change)."""
        val = self._had_shading_update
        self._had_shading_update = False
        return val

    def invalidate_meshes_with_image(self, image_name: str):
        """Force re-flush of meshes whose Base Color uses the given image.

        Called after texture registration so the material blob gets the correct
        tex_id (first flush had tex_id=0 because registration was pending).
        """
        if bpy is None:
            return
        for obj in bpy.data.objects:
            if obj.type != "MESH" or obj.active_material is None:
                continue
            mat = obj.active_material
            if not mat.use_nodes or not mat.node_tree:
                continue
            for node in mat.node_tree.nodes:
                if node.type != "BSDF_PRINCIPLED":
                    continue
                bc = node.inputs.get("Base Color")
                if bc is None or not bc.is_linked:
                    continue
                for link in bc.links:
                    if (link.from_node.type == "TEX_IMAGE" and
                            link.from_node.image and
                            link.from_node.image.name == image_name):
                        # Clear geometry hash → forces UPDATE_VERTEX re-flush
                        self._geometry_hashes.pop(obj.name, None)
                        self._pending[obj.name] = UPDATE_VERTEX
                        break

    def set_resize_callback(self, callback):
        """Set a callback for SHM resize notifications: callback(new_size, new_region_size)."""
        self._resize_callback = callback

    @staticmethod
    def _hash_transform(mat) -> int:
        """FNV-1a hash of 4×4 matrix floats for change detection."""
        h = 14695981039346656037
        for row in mat:
            for val in row:
                bits = struct.pack("<f", val)
                for b in bits:
                    h ^= b
                    h = (h * 1099511628211) & 0xFFFFFFFFFFFFFFFF
        return h

    # -- Depsgraph handler (accumulate only, no SHM writes) ----------------

    def _on_depsgraph_update(self, scene, depsgraph):
        """Called after every depsgraph evaluation. Accumulates changes."""
        self._last_depsgraph = depsgraph

        for update in depsgraph.updates:
            obj = update.id

            # Detect image data changes (texture paint, image editor)
            if isinstance(obj, bpy.types.Image):
                if obj.name in self._tracked_images:
                    self._dirty_images.add(obj.name)
                continue

            # Detect material parameter changes (procedural texture re-bake)
            if isinstance(obj, bpy.types.Material):
                if update.is_updated_shading:
                    self._had_shading_update = True
                continue

            if not isinstance(obj, bpy.types.Object):
                continue

            name = obj.name

            # Skip echoes for objects that Godot just told us about,
            # but still cache the transform hash so unsuppress doesn't
            # trigger false change detection
            if name in self._suppressed_objects:
                if update.is_updated_transform:
                    self._transform_hashes[name] = self._hash_transform(obj.matrix_world)
                continue

            existing = self._pending.get(name, -1)

            # Detect geometry changes: explicit flag OR geometry nodes modifier
            # with any update (geometry nodes often fire is_updated_transform
            # instead of is_updated_geometry when inputs change)
            is_geo_change = update.is_updated_geometry
            if not is_geo_change and update.is_updated_transform:
                if obj.type == "MESH" and any(
                    m.type == "NODES" for m in obj.modifiers
                ):
                    is_geo_change = True

            if is_geo_change:
                # Geometry change: upgrade to at least VERTEX, or FULL if new
                if existing == -1:
                    if name not in self._name_to_id:
                        self._pending[name] = UPDATE_FULL
                    else:
                        self._pending[name] = UPDATE_VERTEX
                elif existing == UPDATE_TRANSFORM:
                    self._pending[name] = UPDATE_FULL
                # else: already VERTEX or FULL, keep it

            # Material/shading change: needs geometry re-export to include
            # updated material data in the blob
            if update.is_updated_shading and obj.type == "MESH":
                existing = self._pending.get(name, -1)
                if existing == -1:
                    if name not in self._name_to_id:
                        self._pending[name] = UPDATE_FULL
                    else:
                        self._pending[name] = UPDATE_VERTEX
                elif existing == UPDATE_TRANSFORM:
                    self._pending[name] = UPDATE_VERTEX

            if update.is_updated_transform:
                # Hash-based jitter reduction: skip if transform unchanged
                new_hash = self._hash_transform(obj.matrix_world)
                if new_hash == self._transform_hashes.get(name, -1):
                    continue
                self._transform_hashes[name] = new_hash

                if existing == -1:
                    if name not in self._name_to_id:
                        self._pending[name] = UPDATE_FULL
                    else:
                        self._pending[name] = UPDATE_TRANSFORM
                # else: don't downgrade from VERTEX/FULL to TRANSFORM

    # -- Geometry nodes instance realization --------------------------------

    @staticmethod
    def _realize_instances(obj, depsgraph):
        """Collect geometry nodes instances and combine into a single Blender mesh.

        When geometry nodes use Instance on Points (or similar), to_mesh()
        returns empty because instances aren't realized. This method iterates
        depsgraph.object_instances, collects all instances parented to `obj`,
        transforms their vertices into obj's local space, and creates a
        combined mesh.
        """
        parent_inv = obj.matrix_world.inverted()
        all_verts = []
        all_faces = []
        vert_offset = 0

        for inst in depsgraph.object_instances:
            if not inst.is_instance:
                continue
            if inst.parent is None or inst.parent.original != obj:
                continue

            inst_obj = inst.object
            inst_mesh = inst_obj.to_mesh()
            if inst_mesh is None or len(inst_mesh.vertices) == 0:
                if inst_mesh is not None:
                    inst_obj.to_mesh_clear()
                continue

            inst_mesh.calc_loop_triangles()
            if len(inst_mesh.loop_triangles) == 0:
                inst_obj.to_mesh_clear()
                continue

            # Transform instance verts to parent local space
            local_mat = parent_inv @ inst.matrix_world
            for v in inst_mesh.vertices:
                pos = local_mat @ v.co
                all_verts.append((pos.x, pos.y, pos.z))

            for tri in inst_mesh.loop_triangles:
                # Reverse winding (swap vertices 1↔2) so from_pydata()
                # produces outward-facing normals. loop_triangles.vertices
                # uses the internal loop order which, when fed to from_pydata
                # as a new standalone mesh, creates inward-facing faces.
                all_faces.append((
                    tri.vertices[0] + vert_offset,
                    tri.vertices[2] + vert_offset,
                    tri.vertices[1] + vert_offset,
                ))

            vert_offset += len(inst_mesh.vertices)
            inst_obj.to_mesh_clear()

        if not all_verts:
            return None

        combined = bpy.data.meshes.new("_bridge_realized_tmp")
        combined.from_pydata(all_verts, [], all_faces)
        combined.update()
        return combined

    # -- SHM auto-resize ---------------------------------------------------

    def _ensure_region_capacity(self, pending: dict):
        """Estimate blob size for pending objects and grow SHM if needed."""
        from .shm_protocol import HEADER_SIZE, ENTRY_SIZE, REGION_SIZE

        estimated_blob = 0
        for name, update_type in pending.items():
            if update_type == UPDATE_DELETE:
                estimated_blob += 64  # small entry
                continue
            obj = bpy.data.objects.get(name)
            if obj is None:
                continue
            estimated_blob += self._MESH_OVERHEAD
            if update_type in (UPDATE_VERTEX, UPDATE_FULL) and obj.type == "MESH":
                mesh = obj.data
                if mesh is not None:
                    tri_count = len(mesh.loop_triangles) if mesh.loop_triangles else len(mesh.polygons) * 2
                    vert_count = tri_count * 3
                    estimated_blob += vert_count * self._BYTES_PER_VERTEX

        table_size = HEADER_SIZE + self._max_objects * ENTRY_SIZE
        needed_region = table_size + estimated_blob
        current_region = self._shm._region_size

        if needed_region <= current_region:
            return  # fits fine

        # Round up to next power-of-2 MB boundary (min 16 MB)
        new_region = max(16 * 1024 * 1024, current_region)
        while new_region < needed_region:
            new_region *= 2

        # Tile ring stays 8 MB
        tile_ring_size = 8 * 1024 * 1024
        new_shm_size = new_region * 2 + tile_ring_size

        print(f"BlenderBridge: Region overflow — resizing SHM "
              f"{self._shm.size // (1024*1024)} MB → {new_shm_size // (1024*1024)} MB "
              f"(region {current_region // (1024*1024)} → {new_region // (1024*1024)} MB, "
              f"estimated blob {estimated_blob // 1024} KB)")

        if self._shm.resize(new_shm_size, new_region):
            # Notify Godot to remap
            if self._resize_callback:
                self._resize_callback(new_shm_size, new_region)
            print(f"BlenderBridge: SHM resize successful")
        else:
            print(f"BlenderBridge: SHM resize FAILED — large objects may not transfer")

    # -- Flush accumulated changes to SHM ----------------------------------

    def flush(self) -> int:
        """Write all pending changes to SHM region 1 atomically.

        Returns the number of objects written. Call this from the timer,
        not from the depsgraph handler.
        """
        # Detect deleted objects: tracked but no longer in bpy.data.objects
        deleted_names = []
        for name in list(self._name_to_id.keys()):
            if bpy.data.objects.get(name) is None:
                deleted_names.append(name)

        if not self._pending and not deleted_names:
            return 0
        if not self._shm.valid:
            return 0

        pending = self._pending
        self._pending = {}

        # Add deletions to pending
        for name in deleted_names:
            pending[name] = UPDATE_DELETE

        # Estimate blob size and resize SHM if needed
        self._ensure_region_capacity(pending)

        self._sequence += 1
        # Region base for absolute mmap writes
        region1_base = self._shm.region_base(1)
        from .shm_protocol import HEADER_SIZE, ENTRY_SIZE
        blob_rel_start = HEADER_SIZE + self._max_objects * ENTRY_SIZE
        blob_rel_offset = blob_rel_start
        blob_abs_offset = region1_base + blob_rel_start

        depsgraph = self._last_depsgraph

        entry_index = 0
        written_updates: dict[str, int] = {}

        for name, update_type in pending.items():
            if entry_index >= self._max_objects:
                break

            # Handle deletion: object no longer exists
            if update_type == UPDATE_DELETE:
                obj_id = self._name_to_id.get(name)
                if obj_id is None:
                    continue
                # Write DELETE entry with no blob data
                self._shm.write_object_entry(
                    index=entry_index,
                    object_id=obj_id,
                    object_type=OBJ_EMPTY,
                    update_type=UPDATE_DELETE,
                    data_offset=0,
                    data_size=0,
                    sequence=self._sequence,
                    sub_type=0,
                    region=1,
                )
                written_updates[name] = UPDATE_DELETE
                entry_index += 1
                del self._name_to_id[name]
                self._transform_hashes.pop(name, None)
                self._geometry_hashes.pop(name, None)
                continue

            # Look up the Blender object
            obj = bpy.data.objects.get(name)
            if obj is None:
                continue

            is_new = name not in self._name_to_id
            obj_id = self.get_or_assign_id(name)

            # New mesh objects always get FULL
            if is_new and obj.type == "MESH":
                update_type = UPDATE_FULL

            obj_type = _blender_type_to_obj_type(obj)
            sub_type = _blender_light_sub_type(obj)
            data_rel_start = blob_rel_offset

            # Write object name (length-prefixed, padded to 4-byte alignment)
            name_bytes = name.encode("utf-8")[:255]
            name_len = len(name_bytes)
            self._shm.write_blob(blob_abs_offset, struct.pack("<B", name_len))
            blob_abs_offset += 1
            blob_rel_offset += 1
            self._shm.write_blob(blob_abs_offset, name_bytes)
            blob_abs_offset += name_len
            blob_rel_offset += name_len
            # Pad to 4-byte alignment
            pad = (4 - ((1 + name_len) % 4)) % 4
            if pad > 0:
                self._shm.write_blob(blob_abs_offset, b"\x00" * pad)
                blob_abs_offset += pad
                blob_rel_offset += pad

            # Write transform (always) and update hash cache
            mat = np.array(obj.matrix_world, dtype=np.float32).flatten()
            self._shm.write_blob_array(blob_abs_offset, mat)
            blob_abs_offset += TRANSFORM_SIZE
            blob_rel_offset += TRANSFORM_SIZE
            self._transform_hashes[name] = self._hash_transform(obj.matrix_world)

            # Write vertex data for geometry updates
            if update_type in (UPDATE_VERTEX, UPDATE_FULL) and obj.type == "MESH":
                if depsgraph is not None:
                    eval_obj = obj.evaluated_get(depsgraph)
                    mesh = eval_obj.to_mesh()
                else:
                    mesh = obj.data
                    eval_obj = None
                realized_mesh = None  # Track standalone mesh that needs bpy.data.meshes.remove()

                if mesh is not None:
                    # Geometry nodes with instances (Instance on Points etc):
                    # to_mesh() doesn't realize instances. Collect them from
                    # depsgraph and combine into a single mesh.
                    mesh.calc_loop_triangles()
                    if (
                        len(mesh.loop_triangles) == 0
                        and depsgraph is not None
                        and any(m.type == "NODES" for m in obj.modifiers)
                    ):
                        if eval_obj is not None:
                            eval_obj.to_mesh_clear()
                        mesh = self._realize_instances(obj, depsgraph)
                        realized_mesh = mesh  # Remember for cleanup
                        eval_obj = None  # mesh is standalone, no clear needed

                if mesh is not None:
                    # Geometry hash check: skip re-export if evaluated mesh
                    # is identical (geometry nodes fire is_updated_geometry
                    # every frame even when output doesn't change)
                    all_verts_hash = np.empty(
                        len(mesh.vertices) * 3, dtype=np.float32
                    )
                    mesh.vertices.foreach_get("co", all_verts_hash)
                    geo_hash = hash(all_verts_hash.tobytes())
                    if geo_hash == self._geometry_hashes.get(name):
                        # Mesh unchanged — downgrade to transform-only or skip
                        if eval_obj is not None:
                            eval_obj.to_mesh_clear()
                        if realized_mesh is not None:
                            bpy.data.meshes.remove(realized_mesh)
                            realized_mesh = None
                        if update_type == UPDATE_VERTEX:
                            # Only geometry was flagged but unchanged — skip entirely
                            blob_abs_offset = region1_base + data_rel_start
                            blob_rel_offset = data_rel_start
                            continue
                        # UPDATE_FULL: still need to write (new object), but skip geometry
                        update_type = UPDATE_TRANSFORM
                        if eval_obj is not None:
                            eval_obj = None
                        mesh = None
                    else:
                        self._geometry_hashes[name] = geo_hash

                if mesh is not None:
                    mesh.calc_loop_triangles()
                    tri_count = len(mesh.loop_triangles)
                    if tri_count > 0:
                        # Export per-loop-triangle-corner data so modifier
                        # normals are correct (weighted normal, geometry
                        # nodes, auto-smooth, etc.)
                        out_vert_count = tri_count * 3

                        # Read all vertex positions
                        all_coords = np.empty(
                            len(mesh.vertices) * 3, dtype=np.float32
                        )
                        mesh.vertices.foreach_get("co", all_coords)
                        all_coords = all_coords.reshape(-1, 3)

                        # Read loop triangle vertex + loop indices
                        tri_vert_idx = np.empty(out_vert_count, dtype=np.int32)
                        mesh.loop_triangles.foreach_get("vertices", tri_vert_idx)
                        tri_loop_idx = np.empty(out_vert_count, dtype=np.int32)
                        mesh.loop_triangles.foreach_get("loops", tri_loop_idx)

                        # Read all loop normals (per face corner — includes
                        # weighted normals, auto-smooth, geometry nodes, etc.)
                        all_loop_normals = np.empty(
                            len(mesh.loops) * 3, dtype=np.float32
                        )
                        mesh.corner_normals.foreach_get(
                            "vector", all_loop_normals
                        )
                        all_loop_normals = all_loop_normals.reshape(-1, 3)

                        # Build per-corner positions and normals
                        out_positions = all_coords[tri_vert_idx].flatten()
                        out_normals = all_loop_normals[tri_loop_idx].flatten()

                        # Write vertex count
                        self._shm.write_blob(
                            blob_abs_offset,
                            struct.pack("<I", out_vert_count),
                        )
                        blob_abs_offset += 4
                        blob_rel_offset += 4

                        # Write positions
                        written = self._shm.write_blob_array(
                            blob_abs_offset, out_positions
                        )
                        blob_abs_offset += written
                        blob_rel_offset += written

                        # Write normals (always present with per-corner data)
                        self._shm.write_blob(
                            blob_abs_offset, struct.pack("<I", 1)
                        )
                        blob_abs_offset += 4
                        blob_rel_offset += 4
                        written = self._shm.write_blob_array(
                            blob_abs_offset, out_normals
                        )
                        blob_abs_offset += written
                        blob_rel_offset += written

                        # No index buffer (per-corner data is sequential)
                        self._shm.write_blob(
                            blob_abs_offset, struct.pack("<I", 0)
                        )
                        blob_abs_offset += 4
                        blob_rel_offset += 4

                        # No smooth flags (normals already encode shading)
                        self._shm.write_blob(
                            blob_abs_offset, struct.pack("<I", 0)
                        )
                        blob_abs_offset += 4
                        blob_rel_offset += 4

                        # Write UV coordinates (float32×2 per vertex)
                        uv_layer = mesh.uv_layers.active
                        if uv_layer is not None:
                            all_uvs = np.empty(
                                len(mesh.loops) * 2, dtype=np.float32
                            )
                            uv_layer.data.foreach_get("uv", all_uvs)
                            all_uvs = all_uvs.reshape(-1, 2)

                            # Index by tri_loop_idx for per-corner data
                            out_uvs = all_uvs[tri_loop_idx].flatten()

                            self._shm.write_blob(
                                blob_abs_offset,
                                struct.pack("<I", out_vert_count),
                            )
                            blob_abs_offset += 4
                            blob_rel_offset += 4
                            written = self._shm.write_blob_array(
                                blob_abs_offset, out_uvs
                            )
                            blob_abs_offset += written
                            blob_rel_offset += written
                        else:
                            # No UVs — write zero count
                            self._shm.write_blob(
                                blob_abs_offset, struct.pack("<I", 0)
                            )
                            blob_abs_offset += 4
                            blob_rel_offset += 4
                    else:
                        # Empty mesh — write vert_count=0 so C++ side
                        # knows to skip geometry (protocol consistency)
                        self._shm.write_blob(
                            blob_abs_offset, struct.pack("<I", 0)
                        )
                        blob_abs_offset += 4
                        blob_rel_offset += 4

                    if eval_obj is not None:
                        eval_obj.to_mesh_clear()
                    if realized_mesh is not None:
                        bpy.data.meshes.remove(realized_mesh)
                        realized_mesh = None

            # Write material data (for meshes, after vertex/UV data)
            if obj.type == "MESH" and obj.active_material is not None:
                mat = obj.active_material
                has_mat = 0
                if mat.use_nodes and mat.node_tree:
                    bsdf = None
                    for node in mat.node_tree.nodes:
                        if node.type == "BSDF_PRINCIPLED":
                            bsdf = node
                            break
                    if bsdf is not None:
                        has_mat = 1
                        self._shm.write_blob(
                            blob_abs_offset, struct.pack("<I", 1)
                        )
                        blob_abs_offset += 4
                        blob_rel_offset += 4

                        base_color = bsdf.inputs["Base Color"].default_value
                        roughness = bsdf.inputs["Roughness"].default_value
                        metallic = bsdf.inputs["Metallic"].default_value
                        emission_color = bsdf.inputs["Emission Color"].default_value
                        emission_strength = bsdf.inputs["Emission Strength"].default_value

                        mat_data = np.array([
                            base_color[0], base_color[1], base_color[2], base_color[3],
                            roughness,
                            metallic,
                            emission_color[0], emission_color[1], emission_color[2], emission_color[3],
                            emission_strength,
                        ], dtype=np.float32)
                        written = self._shm.write_blob_array(blob_abs_offset, mat_data)
                        blob_abs_offset += written
                        blob_rel_offset += written

                        # Write texture_id: look up image texture on Base Color input
                        tex_id = 0
                        base_color_input = bsdf.inputs.get("Base Color")
                        if base_color_input and base_color_input.is_linked:
                            for link in base_color_input.links:
                                if link.from_node.type == "TEX_IMAGE" and link.from_node.image:
                                    tex_id = self._texture_ids.get(link.from_node.image.name, 0)
                                    break
                        self._shm.write_blob(blob_abs_offset, struct.pack("<I", tex_id))
                        blob_abs_offset += 4
                        blob_rel_offset += 4

                if not has_mat:
                    self._shm.write_blob(
                        blob_abs_offset, struct.pack("<I", 0)
                    )
                    blob_abs_offset += 4
                    blob_rel_offset += 4
            elif obj.type == "MESH":
                # No material assigned
                self._shm.write_blob(
                    blob_abs_offset, struct.pack("<I", 0)
                )
                blob_abs_offset += 4
                blob_rel_offset += 4

            data_rel_size = blob_rel_offset - data_rel_start

            # Write object entry to region 1
            self._shm.write_object_entry(
                index=entry_index,
                object_id=obj_id,
                object_type=obj_type,
                update_type=update_type,
                data_offset=data_rel_start,
                data_size=data_rel_size,
                sequence=self._sequence,
                sub_type=sub_type,
                region=1,
            )
            written_updates[name] = update_type
            entry_index += 1

        # Update header in region 1
        if entry_index > 0:
            self._shm.write_header(
                region=1,
                direction=1,
                sequence=self._sequence,
                object_count=entry_index,
            )
            details = ", ".join(
                "{}(id={} t={})".format(n, self._name_to_id.get(n, "?"), ut)
                for n, ut in written_updates.items()
            )
            print(f"BlenderBridge: Flushed {entry_index} change(s) seq={self._sequence} [{details}]")

        return entry_index