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 every fragment whose albedo alpha is below alpha_cutoff. The shadow passes of directional, point and spot lights sample the same alpha, so a cut-out surface casts a shadow with the same holes rather than a solid silhouette. Nothing else is needed: give the material an albedo_map whose alpha channel is the mask, and turn on shadows for the light (shadows are opt-in per light).

Here a flat panel textured with a checkerboard alpha mask hangs over the ground under a DirectionalLight3D, which projects the checkerboard through the holes onto the floor. A solid slab beside it casts an ordinary filled shadow for contrast.

Source

 1# /// simvx
 2# tags = ["3d", "shadows", "cutoff", "alpha-test", "cutout"]
 3# screenshot_frame = 30
 4# ///
 5"""Cutout shadows: an alpha-tested material casts a holed shadow.
 6
 7A material with ``blend="cutoff"`` discards every fragment whose albedo alpha is
 8below ``alpha_cutoff``. The shadow passes of directional, point and spot lights
 9sample the same alpha, so a cut-out surface casts a shadow with the same holes
10rather than a solid silhouette. Nothing else is needed: give the material an
11``albedo_map`` whose alpha channel is the mask, and turn on ``shadows`` for the
12light (shadows are opt-in per light).
13
14Here a flat panel textured with a checkerboard alpha mask hangs over the ground
15under a ``DirectionalLight3D``, which projects the checkerboard through the holes
16onto the floor. A solid slab beside it casts an ordinary filled shadow for
17contrast.
18"""
19
20import numpy as np
21
22from simvx.core import Camera3D, DirectionalLight3D, Material, Mesh, MeshInstance3D, Node3D, WorldEnvironment
23from simvx.graphics import App
24
25
26def checkerboard_alpha(cells: int = 6, cell_px: int = 24) -> np.ndarray:
27    """Near-white RGBA texture whose alpha is 255 on alternate cells and 0 on the rest."""
28    mask = np.indices((cells, cells)).sum(axis=0) % 2 == 0
29    alpha = mask.repeat(cell_px, axis=0).repeat(cell_px, axis=1)
30    tex = np.full((*alpha.shape, 4), 235, dtype=np.uint8)
31    tex[..., 3] = alpha * 255
32    return tex
33
34
35class CutoffShadowScene(Node3D):
36    def on_ready(self):
37        self.add_child(WorldEnvironment())
38        self.add_child(Camera3D(position=(0, 15, 22), fov=55, look_at=(0, 0, 0)))
39        sun = DirectionalLight3D(colour=(1.0, 0.96, 0.88), intensity=1.3, shadows=True, direction=(4, -14, -6))
40        self.add_child(sun)
41
42        ground = Material(colour=(0.55, 0.55, 0.6, 1), roughness=0.9)
43        self.add_child(MeshInstance3D(mesh=Mesh.box((34, 0.4, 34)), material=ground, position=(0, -0.5, 0)))
44
45        # Single-sided on purpose: a double-sided panel would show its underside
46        # through the holes at grazing angles, as strips inside the cutouts.
47        cutout = Material(albedo_map=checkerboard_alpha(), blend="cutoff", alpha_cutoff=0.5, roughness=0.7)
48        self.add_child(MeshInstance3D(mesh=Mesh.box((11, 0.2, 11)), material=cutout, position=(-5, 5, 0)))
49
50        solid = Material(colour=(0.8, 0.35, 0.25, 1), roughness=0.6, metallic=0.1)
51        self.add_child(MeshInstance3D(mesh=Mesh.box((5, 0.2, 5)), material=solid, position=(9, 5, 0)))
52
53
54if __name__ == "__main__":
55    App(title="Cutout Shadows", width=800, height=600).run(CutoffShadowScene())