Cutout shadows

an alpha-tested material casts a holed shadow.

▶ Run in browser

Tags: 3d shadows cutoff alpha-test cutout

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