Cutout shadows

an alpha-tested material casts a holed shadow (RM-G11).

▶ Run in browser

Tags: 3d shadows cutoff alpha-test cutout

A material with blend="cutoff" discards fragments whose albedo alpha is below alpha_cutoff. Before RM-G11 the shadow pass ignored the albedo and such a cutout cast a SOLID silhouette; now the directional (and point/spot) shadow casters sample the albedo alpha and discard holed fragments, so the shadow shows the cutout. Here a horizontal panel textured with a checkerboard alpha mask hangs over the ground under a directional sun: the sun projects the checkerboard through the panel’s holes onto the floor. A solid opaque slab beside it casts an ordinary filled shadow for contrast.

Usage: uv run python examples/features/3d/cutoff_shadows.py

Source

  1"""Cutout shadows: an alpha-tested material casts a holed shadow (RM-G11).
  2
  3A material with ``blend="cutoff"`` discards fragments whose albedo alpha is below
  4``alpha_cutoff``. Before RM-G11 the shadow pass ignored the albedo and such a
  5cutout cast a SOLID silhouette; now the directional (and point/spot) shadow
  6casters sample the albedo alpha and discard holed fragments, so the shadow shows
  7the cutout. Here a horizontal panel textured with a checkerboard alpha mask hangs
  8over the ground under a directional sun: the sun projects the checkerboard through
  9the panel's holes onto the floor. A solid opaque slab beside it casts an ordinary
 10filled shadow for contrast.
 11
 12# /// simvx
 13# tags = ["3d", "shadows", "cutoff", "alpha-test", "cutout"]
 14# screenshot_frame = 30
 15# ///
 16
 17Usage:
 18    uv run python examples/features/3d/cutoff_shadows.py
 19"""
 20
 21import numpy as np
 22
 23from simvx.core import (
 24    Camera3D,
 25    DirectionalLight3D,
 26    Input,
 27    InputMap,
 28    Key,
 29    Material,
 30    Mesh,
 31    MeshInstance3D,
 32    Node3D,
 33    WorldEnvironment,
 34)
 35from simvx.graphics import App
 36
 37
 38def _checkerboard_alpha(cells: int = 6, cell_px: int = 24) -> np.ndarray:
 39    """Opaque-white RGBA with a checkerboard alpha mask (0 or 255 per cell)."""
 40    size = cells * cell_px
 41    tex = np.zeros((size, size, 4), dtype=np.uint8)
 42    tex[..., :3] = 235  # near-white albedo; only the alpha carves the holes
 43    yy, xx = np.mgrid[0:size, 0:size]
 44    mask = ((xx // cell_px) + (yy // cell_px)) % 2 == 0
 45    tex[..., 3] = np.where(mask, 255, 0).astype(np.uint8)
 46    return tex
 47
 48
 49class CutoffShadowScene(Node3D):
 50    def on_ready(self):
 51        InputMap.add_action("quit", [Key.ESCAPE])
 52
 53        self.add_child(WorldEnvironment(name="Env"))
 54
 55        self.add_child(
 56            Camera3D(position=(0, -22, 15), fov=55, look_at=(0, 0, 0), up=(0, 0, 1))
 57        )
 58
 59        sun = DirectionalLight3D(position=(-4, -6, 14))
 60        sun.colour = (1.0, 0.96, 0.88)
 61        sun.intensity = 1.3
 62        sun.shadows = True  # directional shadows are opt-in
 63        sun.look_at((0, 0, 0))
 64        self.add_child(sun)
 65
 66        # Ground plane (opaque) that receives the shadows.
 67        ground_mat = Material(colour=(0.55, 0.55, 0.6, 1), roughness=0.9, metallic=0.0)
 68        ground = MeshInstance3D(mesh=Mesh.cube(), material=ground_mat, position=(0, 0, -0.5))
 69        ground.scale = (34, 34, 0.4)
 70        self.add_child(ground)
 71
 72        # Cutout panel: a thin horizontal slab textured with a checkerboard alpha
 73        # mask. blend="cutoff" discards the transparent cells, so the sun casts a
 74        # checkerboard shadow onto the ground below.
 75        cutout_mat = Material(
 76            albedo_map=_checkerboard_alpha(),
 77            blend="cutoff",
 78            alpha_cutoff=0.5,
 79            roughness=0.7,
 80            metallic=0.0,
 81            double_sided=True,
 82        )
 83        panel = MeshInstance3D(mesh=Mesh.cube(), material=cutout_mat, position=(-5, 0, 5))
 84        panel.scale = (11, 11, 0.2)
 85        self.add_child(panel)
 86
 87        # Opaque slab beside it: casts an ordinary filled shadow for contrast.
 88        solid_mat = Material(colour=(0.8, 0.35, 0.25, 1), roughness=0.6, metallic=0.1)
 89        solid = MeshInstance3D(mesh=Mesh.cube(), material=solid_mat, position=(9, 0, 5))
 90        solid.scale = (5, 5, 0.2)
 91        self.add_child(solid)
 92
 93    def on_update(self, dt):
 94        if Input.is_action_just_pressed("quit"):
 95            self.app.quit()
 96
 97
 98if __name__ == "__main__":
 99    app = App(title="Cutout Shadows", width=800, height=600)
100    app.run(CutoffShadowScene())