OverviewFilesCommitsIssuesDocsReleasesPeers

blender-bridge / blender_addon / shm_protocol.py

309 lines · python ref:
"""Shared memory protocol — Python mirror of src/shm_protocol.h.

Layout must match the C++ structs exactly:
  BridgeHeader  (64 bytes) at offset 0
  ObjectEntry[] (32 bytes each) at offset 64
  Data blob region after the object table
"""

import mmap
import os
import struct

import numpy as np

# ── BridgeHeader (64 bytes) ──────────────────────────────────────────────────
# char     magic[4]       4s
# uint16_t version        H
# uint8_t  direction      B   (0=godot→blender, 1=blender→godot)
# uint8_t  _pad1          B
# uint64_t sequence       Q
# uint32_t object_count   I
# uint32_t _pad2          I
# uint64_t frame_number   Q
# uint8_t  _reserved[32]  32s
HEADER_FMT = "<4sHBBQIIQ32s"
HEADER_SIZE = 64
assert struct.calcsize(HEADER_FMT) == HEADER_SIZE

# ── ObjectEntry (32 bytes) ───────────────────────────────────────────────────
# uint32_t object_id      I
# uint8_t  object_type    B
# uint8_t  update_type    B
# uint8_t  sub_type       B   (light subtype: 0=POINT, 1=SUN, 2=SPOT)
# uint8_t  _pad           B
# uint32_t data_offset    I
# uint32_t data_size      I
# uint32_t sequence       I
# uint8_t  _reserved[12]  12s
ENTRY_FMT = "<IBBBBIII12s"
ENTRY_SIZE = 32
assert struct.calcsize(ENTRY_FMT) == ENTRY_SIZE

# ── Object / Update type constants ───────────────────────────────────────────
OBJ_MESH = 0
OBJ_LIGHT = 1
OBJ_CAMERA = 2
OBJ_EMPTY = 3

UPDATE_TRANSFORM = 0
UPDATE_VERTEX = 1
UPDATE_MATERIAL = 2
UPDATE_FULL = 3
UPDATE_DELETE = 4

# ── Light subtypes ──────────────────────────────────────────────────────────
LIGHT_POINT = 0
LIGHT_SUN = 1    # DirectionalLight3D ↔ Blender "SUN"
LIGHT_SPOT = 2

# ── Transform size ───────────────────────────────────────────────────────────
TRANSFORM_SIZE = 64  # 4×4 float32 matrix

# ── Region layout ────────────────────────────────────────────────────────────
# SHM is divided into 3 zones:
#   Region 0 [0, REGION_SIZE):              Godot→Blender
#   Region 1 [REGION_SIZE, 2*REGION_SIZE):  Blender→Godot
#   Tile ring [2*REGION_SIZE, SHM_SIZE):    Texture streaming
REGION_SIZE = 8 * 1024 * 1024  # 8 MB per region


class ShmRegion:
    """POSIX shared memory wrapper matching the C++ ShmRegion class.

    Supports split-region mode: the SHM is divided into two equal halves.
      Region 0 (offset 0):        Godot writes, Blender reads
      Region 1 (offset size/2):   Blender writes, Godot reads
    """

    def __init__(self):
        self._mm = None
        self._fd = -1
        self._name = None
        self._size = 0
        self._region_size = REGION_SIZE  # 8 MB per region (fixed)
        self._owner = False

    # ── Lifecycle ────────────────────────────────────────────────────────

    def create(self, name: str, size: int) -> bool:
        """Create a new shared memory region (owner — will unlink on close)."""
        self._name = name
        path = f"/dev/shm{name}"
        try:
            self._fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o666)
            os.ftruncate(self._fd, size)
            self._mm = mmap.mmap(self._fd, size)
            self._size = size
            self._owner = True
            # Zero-fill
            self._mm[:] = b"\x00" * size
            return True
        except OSError:
            self.close()
            return False

    def open(self, name: str, size: int) -> bool:
        """Open an existing shared memory region (reader — will NOT unlink).

        Uses the actual file size and derives region_size from layout:
        shm = region0 + region1 + 8MB_tile_ring → region_size = (actual - 8MB) / 2
        """
        self._name = name
        path = f"/dev/shm{name}"
        try:
            self._fd = os.open(path, os.O_RDWR)
            # Use actual file size (handles resize and version mismatch)
            actual_size = os.fstat(self._fd).st_size
            if actual_size != size:
                print(
                    f"BlenderBridge: SHM size {actual_size} "
                    f"(expected {size}), using actual."
                )
            size = actual_size
            self._mm = mmap.mmap(self._fd, size)
            self._size = size
            # Derive region size from layout: 2 regions + 8 MB tile ring
            tile_ring = 8 * 1024 * 1024
            if size > tile_ring:
                self._region_size = (size - tile_ring) // 2
            else:
                self._region_size = REGION_SIZE
            self._owner = False
            return True
        except OSError:
            self.close()
            return False

    def resize(self, new_size: int, new_region_size: int) -> bool:
        """Grow the shared memory and remap. Returns True on success.

        Blender calls this when a mesh blob exceeds the current region.
        The fd stays open; we ftruncate then close+reopen the mmap.
        """
        if self._fd < 0:
            return False
        try:
            # Grow the backing file
            os.ftruncate(self._fd, new_size)
            # Close old mapping
            if self._mm is not None:
                self._mm.close()
            # Open new mapping at new size
            self._mm = mmap.mmap(self._fd, new_size)
            old_size = self._size
            self._size = new_size
            self._region_size = new_region_size
            # Zero-fill the newly added region (old_size..new_size)
            self._mm[old_size:new_size] = b"\x00" * (new_size - old_size)
            return True
        except OSError as e:
            print(f"BlenderBridge: SHM resize failed: {e}")
            return False

    def close(self):
        """Unmap and optionally unlink the shared memory."""
        if self._mm is not None:
            self._mm.close()
            self._mm = None
        if self._fd >= 0:
            os.close(self._fd)
            self._fd = -1
        if self._owner and self._name:
            try:
                os.unlink(f"/dev/shm{self._name}")
            except OSError:
                pass
            self._name = None
        self._size = 0

    @property
    def valid(self) -> bool:
        return self._mm is not None

    @property
    def size(self) -> int:
        return self._size

    # ── Region helpers ──────────────────────────────────────────────────

    def region_base(self, region: int) -> int:
        """Return the absolute byte offset for the start of a region."""
        return region * self._region_size

    # ── Header read/write ────────────────────────────────────────────────

    def read_header(self, region: int = 0) -> dict:
        base = self.region_base(region)
        data = self._mm[base : base + HEADER_SIZE]
        magic, ver, direction, _pad, seq, obj_count, _pad2, frame, _res = (
            struct.unpack(HEADER_FMT, data)
        )
        return {
            "magic": magic,
            "version": ver,
            "direction": direction,
            "sequence": seq,
            "object_count": obj_count,
            "frame_number": frame,
        }

    def write_header(
        self,
        region: int = 0,
        magic: bytes = b"BGOD",
        version: int = 1,
        direction: int = 0,
        sequence: int = 0,
        object_count: int = 0,
        frame_number: int = 0,
    ):
        base = self.region_base(region)
        data = struct.pack(
            HEADER_FMT,
            magic,
            version,
            direction,
            0,  # _pad1
            sequence,
            object_count,
            0,  # _pad2
            frame_number,
            b"\x00" * 32,  # _reserved
        )
        self._mm[base : base + HEADER_SIZE] = data

    # ── Object entry read/write ──────────────────────────────────────────

    def read_object_entry(self, index: int, region: int = 0) -> dict:
        base = self.region_base(region)
        offset = base + HEADER_SIZE + index * ENTRY_SIZE
        data = self._mm[offset : offset + ENTRY_SIZE]
        obj_id, obj_type, upd_type, sub_type, _pad, data_off, data_sz, seq, _res = (
            struct.unpack(ENTRY_FMT, data)
        )
        return {
            "object_id": obj_id,
            "object_type": obj_type,
            "update_type": upd_type,
            "sub_type": sub_type,
            "data_offset": data_off,
            "data_size": data_sz,
            "sequence": seq,
        }

    def write_object_entry(
        self,
        index: int,
        object_id: int,
        object_type: int,
        update_type: int,
        data_offset: int,
        data_size: int,
        sequence: int,
        sub_type: int = 0,
        region: int = 0,
    ):
        base = self.region_base(region)
        offset = base + HEADER_SIZE + index * ENTRY_SIZE
        data = struct.pack(
            ENTRY_FMT,
            object_id,
            object_type,
            update_type,
            sub_type,
            0,  # _pad
            data_offset,
            data_size,
            sequence,
            b"\x00" * 12,  # _reserved
        )
        self._mm[offset : offset + ENTRY_SIZE] = data

    # ── Blob access ──────────────────────────────────────────────────────

    def read_blob(self, offset: int, size: int) -> np.ndarray:
        """Read raw float32 data from the blob region (returns a copy)."""
        return np.frombuffer(
            self._mm, dtype=np.float32, count=size // 4, offset=offset
        ).copy()

    def write_blob(self, offset: int, data: bytes):
        """Write raw bytes to the blob region."""
        self._mm[offset : offset + len(data)] = data

    def write_blob_array(self, offset: int, arr: np.ndarray):
        """Write a numpy array to the blob region (preserves dtype)."""
        raw = arr.tobytes()
        self._mm[offset : offset + len(raw)] = raw
        return len(raw)

    # ── Layout helpers ───────────────────────────────────────────────────

    def data_blob_offset(self, max_objects: int, region: int = 0) -> int:
        """Offset where data blobs start (past header + object table).

        Returns an absolute offset into the SHM (region base + header + table).
        """
        return self.region_base(region) + HEADER_SIZE + max_objects * ENTRY_SIZE