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