Shadows of cut-out leaves: the source
Open the live demo · Read the guide · View on GitHub
Regions the guide quotes: texture (line 13), leaves (line 67), sun (line 84), live (line 107).
1/// A cut-out caster throws a cut-out shadow: the holes in a leaf's texture are
2/// holes in its shadow too.
3///
4/// Quoted by `masked_shadow_casters.md` and shown whole in the Source tab.
5library;
7import 'dart:typed_data';
9import 'package:flutter3d/flutter3d.dart';
10import 'package:flutter3d_showcase/src/demo/demo.dart';
11import 'package:vector_math/vector_math.dart';
13/// A grid of soft discs on a clear background: opaque in the middle of each
14/// disc, fading to nothing at its rim.
15final class DiscsTexture extends ProceduralTexture {
16 const DiscsTexture({this.size = 64, this.cells = 4});
19 final int size;
20 final int cells;
23 ByteData encode() {
24 final Uint8List bytes = Uint8List(size * size * 4);
25 final double cell = size / cells;
26 for (var y = 0; y < size; y++) {
27 for (var x = 0; x < size; x++) {
28 final double dx = (x + 0.5) % cell - cell / 2;
29 final double dy = (y + 0.5) % cell - cell / 2;
30 final double radius = Vector2(dx, dy).length / cell;
31 final double alpha = ((0.5 - radius) / 0.35).clamp(0.0, 1.0);
32 final int at = (y * size + x) * 4;
33 bytes[at] = 70;
34 bytes[at + 1] = 150;
35 bytes[at + 2] = 70;
36 bytes[at + 3] = (alpha * 255).round();
37 }
38 }
39 return bytes.buffer.asByteData();
40 }
41}
43final class MaskedShadowCastersDemo extends ShowcaseDemo {
44 bool cutOut = true;
45 double cutoff = 0.5;
47 late final Material _leaves;
50 void configureView(DemoContext context) {
51 context.orbit
52 ..distance = 8.0
53 ..pitch = 0.75
54 ..yaw = 0.4;
55 context.orbit.target.setValues(0.5, 0.5, 0.0);
56 }
59 Scene build(DemoContext context) {
60 final Material stone = Material(
61 name: 'stone',
62 baseColor: Vector4(0.78, 0.76, 0.72, 1.0),
63 roughness: 0.9,
64 doubleSided: true,
65 );
67 _leaves = Material(
68 name: 'leaves',
69 albedo: const DiscsTexture().upload(context.device),
70 alphaMode: MaterialAlphaMode.mask,
71 alphaCutoff: cutoff,
72 doubleSided: true,
73 roughness: 0.8,
74 );
75 final MeshNode canopy = MeshNode(
76 DeviceMesh.upload(
77 context.device,
78 const PlaneShape(width: 3, depth: 3).build(),
79 ),
80 _leaves,
81 name: 'canopy',
82 )..setPosition(-0.5, 1.6, 0.0);
84 final LightNode sun = LightNode(
85 name: 'sun',
86 intensity: 3.0,
87 castsShadow: true,
88 )..setLocalForward(Vector3(-0.5, -0.8, -0.3));
90 return Scene()
91 ..add(
92 MeshNode(
93 DeviceMesh.upload(
94 context.device,
95 const PlaneShape(width: 10, depth: 10).build(),
96 ),
97 stone,
98 name: 'ground',
99 ),
100 )
101 ..add(canopy)
102 ..add(sun);
103 }
106 void update(DemoContext context, double dt) {
107 _leaves
108 ..alphaMode = cutOut ? MaterialAlphaMode.mask : MaterialAlphaMode.opaque
109 ..alphaCutoff = cutoff;
110 }
113 RenderSettings settings(DemoContext context) => const RenderSettings(
114 shadows: ShadowSettings(cascades: 1, resolution: 1024),
115 );
118 List<DemoControl> controls(DemoContext context) => <DemoControl>[
119 ToggleControl(
120 'Cut out',
121 value: () => cutOut,
122 onChanged: (bool v) => cutOut = v,
123 ),
124 SliderControl(
125 'Cutoff',
126 min: 0.05,
127 max: 0.95,
128 value: () => cutoff,
129 onChanged: (double v) => cutoff = v,
130 ),
131 ];
134 void verify(Scene scene, FrameResult frame) {
135 if (_leaves.alphaMode != MaterialAlphaMode.mask || _leaves.albedo == null) {
136 throw StateError('the canopy is not a masked caster');
137 }
138 if (!frame.passes.any((FramePass p) => p.name == 'directional shadows')) {
139 throw StateError('the sun drew no shadow map');
140 }
141 if (frame.shadowCasters < 2) {
142 throw StateError('the canopy and the ground were not both drawn');
143 }
144 }
145}