Cutout shadows¶
an alpha-tested material casts a holed shadow (RM-G11).
▶ Run in browserTags: 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())