SubViewport Occlusion

an offscreen view that culls what its own camera cannot see.

📄 Docs only

Tags: 3d subviewport culling performance

Occlusion culling skips drawing objects hidden behind others. Set SubViewport(use_occlusion=True) and the view runs its own Hi-Z occlusion cull for its own camera, sized to its own target and independent of the main camera. Only the view opts in here: the main scene renders without occlusion culling.

Inside the SubViewport a brown wall stands close to the camera with a dense field of blue cubes packed behind it. The wall hides the field, so the cull skips it and it is never drawn into the texture. Orange cubes on either side of the wall stay in view, spinning, to show the visible part of the feed still renders.

The feed is shown twice: on an in-world monitor through Material(albedo_map=sub_viewport) and as a picture-in-picture Sprite2D(texture=sub_viewport) in the bottom-left corner. Both bindings are assigned once and follow the live texture. The label at the top left reads the view’s cull counts each frame from app.last_telemetry: view_occlusion_total is how many instances the view submitted and view_occlusion_drawn how many survived the cull.

Source

  1# /// simvx
  2# tags = ["3d", "subviewport", "culling", "performance"]
  3# screenshot_frame = 60
  4# web = { disabled = true, reason = "per-view occlusion culling is desktop-only until the WebGPU Hi-Z port" }
  5# ///
  6"""SubViewport Occlusion: an offscreen view that culls what its own camera cannot see.
  7
  8Occlusion culling skips drawing objects hidden behind others. Set
  9`SubViewport(use_occlusion=True)` and the view runs its own Hi-Z occlusion cull
 10for its own camera, sized to its own target and independent of the main camera.
 11Only the view opts in here: the main scene renders without occlusion culling.
 12
 13Inside the SubViewport a brown wall stands close to the camera with a dense
 14field of blue cubes packed behind it. The wall hides the field, so the cull
 15skips it and it is never drawn into the texture. Orange cubes on either side of
 16the wall stay in view, spinning, to show the visible part of the feed still
 17renders.
 18
 19The feed is shown twice: on an in-world monitor through
 20`Material(albedo_map=sub_viewport)` and as a picture-in-picture
 21`Sprite2D(texture=sub_viewport)` in the bottom-left corner. Both bindings are
 22assigned once and follow the live texture. The label at the top left reads the
 23view's cull counts each frame from `app.last_telemetry`: `view_occlusion_total`
 24is how many instances the view submitted and `view_occlusion_drawn` how many
 25survived the cull.
 26"""
 27
 28from itertools import product
 29
 30from simvx.core import (
 31    Anchor,
 32    Camera3D,
 33    CanvasLayer,
 34    Control,
 35    DirectionalLight3D,
 36    Label,
 37    Material,
 38    Mesh,
 39    MeshInstance3D,
 40    Node,
 41    Sprite2D,
 42    SubViewport,
 43)
 44from simvx.graphics import App
 45
 46FIELD_COLS, FIELD_ROWS, FIELD_LAYERS = 10, 6, 4
 47FIELD_SPACING = 0.85
 48PIP_SIZE = 240
 49
 50
 51class SubViewportOcclusionScene(Node):
 52    def on_ready(self):
 53        self.add_child(Camera3D(position=(0, 2, 6), look_at=(0, 1, 0)))
 54        self.add_child(DirectionalLight3D(intensity=2.0, direction=(-0.4, -1.0, -0.6)))
 55
 56        # The offscreen view: its own camera, light, wall and cubes. Its cull runs for this camera alone.
 57        sub = self.add_child(SubViewport(size=(512, 512), use_occlusion=True))
 58        sub.add_child(Camera3D(position=(0, 1.5, 9), look_at=(0, 1.5, -8)))
 59        sub.add_child(DirectionalLight3D(intensity=2.5, direction=(-0.5, -0.8, -0.5)))
 60
 61        # Wide enough to hide the whole field, narrow enough to leave the flank cubes in view.
 62        wall_mat = Material(colour=(0.55, 0.42, 0.30), roughness=0.8)
 63        sub.add_child(MeshInstance3D(mesh=Mesh.box((5, 5, 0.4)), material=wall_mat, position=(0, 2, 2)))
 64
 65        field_mesh = Mesh.box(0.32)
 66        field_mat = Material(colour=(0.30, 0.55, 0.85), roughness=0.5)
 67        x0 = -(FIELD_COLS - 1) * FIELD_SPACING / 2
 68        for cx, cy, cz in product(range(FIELD_COLS), range(FIELD_ROWS), range(FIELD_LAYERS)):
 69            position = (x0 + cx * FIELD_SPACING, 0.2 + cy * FIELD_SPACING, -2 - cz * FIELD_SPACING)
 70            sub.add_child(MeshInstance3D(mesh=field_mesh, material=field_mat, position=position))
 71
 72        flank_mesh = Mesh.box(0.6)
 73        flank_mat = Material(colour=(0.95, 0.55, 0.2), roughness=0.4, emissive_colour=(0.4, 0.2, 0.05))
 74        self.flanks = [
 75            sub.add_child(
 76                MeshInstance3D(mesh=flank_mesh, material=flank_mat, position=(side * (3.8 + k * 0.4), 1.5, -k * 0.5))
 77            )
 78            for side in (-1, 1)
 79            for k in range(3)
 80        ]
 81
 82        # The live feed on a monitor in the main scene.
 83        monitor_mat = Material(colour=(1, 1, 1), roughness=1.0, albedo_map=sub)
 84        self.add_child(MeshInstance3D(mesh=Mesh.box((3.4, 3.4, 0.08)), material=monitor_mat, position=(0, 1.2, 0)))
 85
 86        hud = self.add_child(CanvasLayer())
 87        self.status = hud.add_child(Label("View cull: waiting for the first frame", anchor=Anchor.TOP_LEFT, inset=12))
 88        hud.add_child(
 89            Label(
 90                f"use_occlusion = True: {FIELD_COLS * FIELD_ROWS * FIELD_LAYERS} cubes behind the view's wall",
 91                anchor=Anchor.BOTTOM_RIGHT,
 92                inset=12,
 93            )
 94        )
 95        # Picture-in-picture: the same feed as a sprite, centred in an anchored frame.
 96        pip = hud.add_child(Control(anchor=Anchor.BOTTOM_LEFT, size=(PIP_SIZE, PIP_SIZE), inset=12))
 97        pip.add_child(Sprite2D(texture=sub, position=(PIP_SIZE / 2, PIP_SIZE / 2), width=PIP_SIZE, height=PIP_SIZE))
 98
 99    def on_update(self, dt):
100        for i, flank in enumerate(self.flanks):
101            flank.rotate_y((0.8 + 0.1 * i) * dt)
102        telemetry = self.app.last_telemetry
103        if total := telemetry.get("view_occlusion_total"):
104            drawn = telemetry["view_occlusion_drawn"]
105            self.status.text = f"View cull: drew {drawn} of {total} instances, culled {total - drawn}"
106
107
108if __name__ == "__main__":
109    App(title="SubViewport Occlusion", width=1280, height=720).run(SubViewportOcclusionScene())