unlit3d_examples/scenes/lit.rs
1//! The lit scene: Lambert shading under every kind of built-in light.
2//!
3//! One large surface faces the camera, and each of the four light types lays a
4//! shape on it, so a frame that drops a light, packs one wrong or clusters one
5//! out of its cells changes the pixels rather than only the brightness:
6//!
7//! * the two **directional** lights are constant over the whole scene and own no
8//! shape of their own: one rakes the floor from above and one faces the wall,
9//! so together they give every surface a gradient without competing with the
10//! bounded lights' patches;
11//! * the **point** lights are the three small, saturated sources floating where
12//! their distance to the wall equals their height above the floor, so each
13//! pool runs up the wall and across the floor at the same reach, and the three
14//! pools overlap just enough to read as one band of mixed colour;
15//! * the **spot** light stands at the far left, level with the floor, pointing
16//! at the wall: its cone cuts a half disc on the wall with the straight edge on
17//! the floor, and a cone packed with the wrong outer angle would light far more
18//! of the wall than a half disc;
19//! * the **quad** lights are the two area sources: a violet rectangle standing
20//! upright right where it meets the floor, and a teal triangle standing on its
21//! base just above the floor to its left. They are the lights whose shading is
22//! not a point term, so a form factor that is zero, negative or far too bright
23//! shows up as a missing or blown-out patch -- and the pane's outline lands on
24//! the wall as a triangle, which a form factor that integrates the wrong
25//! corners cannot match.
26//!
27//! Each light owns its own corner of the frame, so the patches stay apart and
28//! every shape reads on its own.
29//!
30//! Every light has a colour no other has -- gold and blue suns, red/green/blue
31//! points, an amber spot, a violet quad -- so a light that is packed, clustered
32//! or shaded wrongly changes a hue rather than only a brightness.
33//!
34//! The wall is a relief rather than a plane, displaced out of its own plane by a
35//! vertex snippet the scene splices into the built-in lit stage — the same hook a
36//! caller uses to shape its own geometry. Being displaced, it is drawn from a
37//! mesh with no normal channel, so its fragments take the normal the displaced
38//! surface actually has from the world positions across the primitive; the floor
39//! in front of it is a plain plane and keeps its own flat normals, so one frame
40//! draws both normal sources.
41
42use super::{
43 LitSurface, PlacedLight, SceneControl, SceneDef, SceneOptions, allocate_surface,
44 camera_looking_at, grid,
45};
46use unlit_ecs::bundle;
47use unlit_wgpu::pipeline::LitShader;
48use unlit3d::prelude::*;
49
50/// How far above the floor, and how far back, the camera sits.
51const CAMERA_EYE: glam::Vec3 = glam::Vec3::new(0.0, 2.05, 1.85);
52
53/// The point the camera looks at: the middle of the floor, where the quad
54/// light hangs.
55const CAMERA_TARGET: glam::Vec3 = glam::Vec3::new(0.0, 0.9, -3.5);
56
57/// The light every fragment gets whether any light reaches it, tinted cold so
58/// the parts no light reaches read as shaded rather than as black.
59const AMBIENT: glam::Vec4 = glam::Vec4::new(0.012, 0.014, 0.02, 0.0);
60
61/// The floor: its depth along z, its width along x, and how far it reaches in
62/// front of the camera. The back edge is placed at the wall, so shortening the
63/// floor crops empty foreground rather than opening a gap at the wall.
64const FLOOR: (f32, f32, f32) = (5.2, 11.5, 1.0);
65
66/// How many quads the wall is split into along x and along y. The relief is a
67/// vertex displacement, so this is the resolution it is sampled at.
68const WALL_SEGMENTS: (u32, u32) = (1280, 400);
69
70/// The wall's relief: how far the noise stands out of it, and how large one cell
71/// of its lattice is along x and along y. A noise octave has no direction of its
72/// own, so unlike a wave train it reads the same however the frame is cropped.
73const RELIEF: (f32, f32, f32) = (0.012, 0.055, 0.045);
74
75/// The back wall: how wide, how tall, and how far behind the origin. It stands
76/// behind the floor so the key light has a second surface at a right angle to
77/// the floor, which is what shows a normal stream that is right for one face
78/// and wrong for the other.
79const WALL: (f32, f32, f32) = (11.5, 3.6, -4.2);
80
81/// The three point lights, as x position and colour.
82const POINT_LIGHTS: [(f32, glam::Vec4); 3] = [
83 (0.15, glam::Vec4::new(1.0, 0.08, 0.05, 0.0)),
84 (0.8, glam::Vec4::new(0.1, 1.0, 0.1, 0.0)),
85 (1.45, glam::Vec4::new(0.1, 0.2, 1.0, 0.0)),
86];
87
88/// How far above the floor the point lights float, and how far in front of the
89/// origin. They float near the back wall so each pool runs up it as well.
90const POINT_HEIGHT: f32 = 0.5;
91const POINT_DEPTH: f32 = WALL.2 + 0.55;
92
93/// The reach of each point light: far enough to pool on the floor beneath it
94/// and to climb the wall behind it, short enough that the pools stay apart.
95const POINT_RANGE: f32 = 1.9;
96
97/// The intensity of each point light, in the units the shader's falloff uses.
98const POINT_INTENSITY: f32 = 0.24;
99
100/// The spot light that hangs in front of the wall: where it hangs, and its
101/// colour.
102const OVERHEAD_SPOT: (glam::Vec3, glam::Vec4) = (
103 glam::Vec3::new(-3.05, 0.12, -1.5),
104 glam::Vec4::new(1.0, 0.55, 0.12, 0.0),
105);
106
107/// What that spot aims at: high on the wall, so the cone splashes across the
108/// wall and reaches down onto the floor in front of it. It hangs off to the
109/// right, clear of the standing pane's patch.
110const OVERHEAD_TARGET: glam::Vec3 = glam::Vec3::new(-3.05, 0.12, -4.2);
111
112/// The cone's inner and outer half-angles in degrees, and how bright the spot
113/// is.
114const SPOT_INNER: f32 = 26.0;
115const SPOT_OUTER: f32 = 38.0;
116const SPOT_INTENSITY: f32 = 5.0;
117
118/// The flat pane over the front of the floor: its size, how far up it hangs,
119/// how far in front of the origin, and how bright it is.
120///
121/// It hangs just above the crests of the relief, because a polygon light is an
122/// area source: the closer it sits to what it lights, the sharper the edge of
123/// its patch, and lifted high it would only wash the whole floor.
124const QUAD_SIZE: (f32, f32) = (1.25, 1.0);
125const QUAD_HEIGHT: f32 = 0.5;
126const QUAD_X: f32 = 3.0;
127const QUAD_DEPTH: f32 = -3.85;
128const QUAD_INTENSITY: f32 = 0.4;
129const QUAD_COLOR: glam::Vec4 = glam::Vec4::new(0.75, 0.35, 1.0, 0.0);
130
131/// A second polygon light, standing upright instead of lying flat, and shaped
132/// as a triangle: a polygon light is any convex outline, and three corners are
133/// enough to show it. The frame merges the fourth corner onto the third.
134const TRIANGLE_COLOR: glam::Vec4 = glam::Vec4::new(0.2, 1.0, 0.75, 0.0);
135const TRIANGLE_INTENSITY: f32 = 0.3;
136/// Where the pane's base edge floats: a little in front of the wall and a
137/// little above the floor, so the outline it throws stays compact the way the
138/// flat pane's does.
139const TRIANGLE_FOOT: glam::Vec3 = glam::Vec3::new(-1.45, 0.2, -3.85);
140/// How far the standing pane tips back from upright.
141///
142/// A pane square to the wall shines across it and nothing else, because the
143/// floor lies behind its radiating face. Tipping it back turns the ray down, so
144/// the same polygon throws its outline on the wall and rakes the floor in front
145/// of it.
146const TRIANGLE_TILT_DEGREES: f32 = 12.0;
147/// The triangle's corners, in its own plane: a wide base and one apex, so the
148/// patch it throws reads as a triangle and not as a band.
149const TRIANGLE_VERTICES: [glam::Vec3; 4] = [
150 glam::Vec3::new(-0.85, -0.65, 0.0),
151 glam::Vec3::new(0.85, -0.65, 0.0),
152 glam::Vec3::new(0.0, 0.95, 0.0),
153 glam::Vec3::new(0.0, 0.95, 0.0),
154];
155
156/// The scene: one relief floor and one wall, lit by one light of every kind.
157pub static SCENE: SceneDef = SceneDef {
158 id: "lit",
159 title: "Lambert lighting",
160 description: "one relief surface under directional, point, spot and quad lights",
161 size: super::BASELINE_SIZE,
162 baseline: Some(super::BASELINE_SIZE),
163 clear: None,
164 frames: 1,
165 step_seconds: None,
166 samples: 1,
167 depth: true,
168 ui: false,
169 reproducible_ui: false,
170 build,
171};
172/// Build the lit scene's world content.
173fn build(
174 world: &mut World,
175 context: RenderContext,
176 pass: Entity,
177 _ui: Option<Entity>,
178 size: (u32, u32),
179 _options: SceneOptions,
180) -> SceneControl {
181 let mut source = MeshSource::new(&world.root_scope(), context);
182 // The lit family, rather than the unlit one: it is the same pipeline with
183 // the variant's lighting flag set, so the mesh and material allocation
184 // below is the unlit pipeline's own.
185 source.register_lit_family(&world.root_scope());
186 // The default options draw unlit; this scene is the one that lights.
187 let mut options = super::default_options();
188 options.unlit = false;
189 let key = LitPipelineKey::new(options.clone());
190 // The wall's own key: the relief rides in the vertex stage, and a shader
191 // is part of the options a variant is compiled from, so the wall draws
192 // through a second pipeline of the same family.
193 let mut relief = options;
194 relief.shader = Some(LitShader::new("lit::wall_relief", "", wall_relief(), ""));
195 let relief_key = LitPipelineKey::new(relief);
196
197 let source_entity = world
198 .scope_mut(pass)
199 .expect("the pass is alive")
200 .spawn(bundle![Source::new(source)]);
201
202 let mut floor = grid(
203 glam::Vec3::new(0.0, 0.0, FLOOR.2 - FLOOR.0 * 0.5),
204 glam::Vec3::Z,
205 glam::Vec3::X,
206 (FLOOR.0, FLOOR.1),
207 (1, 1),
208 );
209 let wall = grid(
210 glam::Vec3::new(0.0, WALL.1 * 0.5, WALL.2),
211 glam::Vec3::X,
212 glam::Vec3::Y,
213 (WALL.0, WALL.1),
214 WALL_SEGMENTS,
215 );
216 // A surface with no normal channel is shaded by the normal its displaced
217 // positions give it, which is what the relief needs; the floor is a plane,
218 // so the mesh's own normal is exactly right for it.
219 floor.normals = Some(vec![glam::Vec3::Y.to_array(); floor.positions.len()]);
220 let floor_mesh = allocate_surface(world, source_entity, &key, &floor);
221 let wall_mesh = allocate_surface(world, source_entity, &relief_key, &wall);
222 let pipeline = GpuRenderPipeline::new(key);
223 let relief_pipeline = GpuRenderPipeline::new(relief_key);
224
225 let camera_entity = super::spawn_in(
226 world,
227 source_entity,
228 bundle![camera_looking_at(CAMERA_EYE, CAMERA_TARGET, size)],
229 );
230 super::spawn_in(world, source_entity, bundle![AmbientLight::new(AMBIENT)]);
231
232 super::spawn_in(
233 world,
234 source_entity,
235 LitSurface {
236 mesh: floor_mesh,
237 pipeline,
238 },
239 );
240 super::spawn_in(
241 world,
242 source_entity,
243 LitSurface {
244 mesh: wall_mesh,
245 pipeline: relief_pipeline,
246 },
247 );
248
249 // Two directional lights, told apart by hue as well as by direction: the
250 // amber key rakes across the floor from above and the blue fill faces the
251 // wall head-on. They are constant over the whole scene, so neither hides the
252 // bounded lights' shapes.
253 for (color, intensity, rotation) in [
254 (
255 glam::Vec4::new(1.0, 0.62, 0.15, 0.0),
256 0.22,
257 glam::Quat::from_rotation_y(0.65) * glam::Quat::from_rotation_x(-0.65),
258 ),
259 (
260 glam::Vec4::new(0.2, 0.45, 1.0, 0.0),
261 0.11,
262 glam::Quat::IDENTITY,
263 ),
264 ] {
265 super::spawn_in(
266 world,
267 source_entity,
268 PlacedLight {
269 light: LightSource::new(DirectionalLight { color, intensity }),
270 translation: glam::Vec3::ZERO,
271 rotation,
272 },
273 );
274 }
275
276 for (x, color) in POINT_LIGHTS {
277 super::spawn_in(
278 world,
279 source_entity,
280 PlacedLight {
281 light: LightSource::new(PointLight {
282 color,
283 intensity: POINT_INTENSITY,
284 range: POINT_RANGE,
285 }),
286 translation: glam::Vec3::new(x, POINT_HEIGHT, POINT_DEPTH),
287 rotation: glam::Quat::IDENTITY,
288 },
289 );
290 }
291
292 // The overhead spot aims at the wall, so its cone shows on both surfaces at
293 // once: the wall takes the middle of it and the floor takes the spill.
294 super::spawn_in(
295 world,
296 source_entity,
297 PlacedLight::aiming(
298 LightSource::new(SpotLight {
299 color: OVERHEAD_SPOT.1,
300 intensity: SPOT_INTENSITY,
301 range: 14.0,
302 inner_angle: SPOT_INNER.to_radians(),
303 outer_angle: SPOT_OUTER.to_radians(),
304 }),
305 OVERHEAD_SPOT.0,
306 OVERHEAD_TARGET,
307 ),
308 );
309
310 // The rectangle stands upright and faces the right of the wall. Its
311 // distance gives it a soft edge, contrasting with the closer triangle.
312 super::spawn_in(
313 world,
314 source_entity,
315 PlacedLight::aiming(
316 LightSource::new(QuadLight::rect(
317 QUAD_COLOR,
318 QUAD_INTENSITY,
319 8.0,
320 QUAD_SIZE.0,
321 QUAD_SIZE.1,
322 )),
323 glam::Vec3::new(QUAD_X, QUAD_HEIGHT, QUAD_DEPTH),
324 glam::Vec3::new(QUAD_X, QUAD_HEIGHT, WALL.2),
325 ),
326 );
327
328 // The triangle stands on the left, aiming mostly at the wall but tipped
329 // down enough for its lower part to spill onto the floor.
330 let tilt = TRIANGLE_TILT_DEGREES.to_radians();
331 let upright = glam::Vec3::new(0.0, tilt.cos(), -tilt.sin());
332 let triangle_center = TRIANGLE_FOOT + upright * TRIANGLE_VERTICES[0].y.abs();
333 super::spawn_in(
334 world,
335 source_entity,
336 PlacedLight::aiming(
337 LightSource::new(QuadLight {
338 color: TRIANGLE_COLOR,
339 intensity: TRIANGLE_INTENSITY,
340 range: 16.0,
341 vertices: TRIANGLE_VERTICES,
342 }),
343 triangle_center,
344 triangle_center + glam::Vec3::new(0.0, -tilt.sin(), -tilt.cos()),
345 ),
346 );
347
348 SceneControl {
349 // A single frame: the lights do not move, so only the camera's aspect
350 // is advanced, keeping a resized window from stretching the floor.
351 advance: Box::new(move |world, _frame, _delta, frame_size| {
352 super::aim_camera(
353 world,
354 source_entity,
355 camera_entity,
356 CAMERA_EYE,
357 CAMERA_TARGET,
358 frame_size,
359 );
360 }),
361 snapshot: Box::new(move |frame| (frame == 0).then(|| "lit.webp".to_owned())),
362 }
363}
364/// The WESL the wall's vertex stage runs, spliced at the built-in stage's
365/// vertex hook.
366///
367/// `world` is the vertex the instance transform placed, and everything after
368/// the hook — the clip position, the world position the lights are addressed
369/// from and the cluster cell — follows the vertex this leaves. The relief fades
370/// out at the wall's borders, so the surface still meets the floor along its
371/// bottom edge and still fills the frame.
372///
373/// The noise runs out of the wall along its own normal rather than along the
374/// world's up axis, which is what makes the wall a surface the lights cross
375/// rather than a backdrop they paint on: a fragment's normal comes from the
376/// displaced positions, so every rise and hollow shades by its own slope.
377///
378/// The snippet runs per vertex, so the segments the wall is split into are the
379/// resolution the noise is sampled at; one lattice cell spans a few segments,
380/// which is what keeps a vertex displacement from turning the noise into flat
381/// facets.
382fn wall_relief() -> String {
383 let (amplitude, cell_x, cell_y) = RELIEF;
384 let half_width = WALL.0 * 0.5;
385 let half_height = WALL.1 * 0.5;
386 format!(
387 "\
388let lattice = vec2<f32>(world.x / {cell_x:.4}, world.y / {cell_y:.4});
389let cell = floor(lattice);
390let offset = fract(lattice);
391let weighted = (3.0 - 2.0 * offset) * offset * offset;
392// An integer hash, not `fract(sin(...))`: `sin` is only specified to a few
393// units in the last place, and the usual trick of multiplying it by a large
394// constant turns that into an unrelated lattice on another driver. Integer
395// arithmetic is exact, so every backend displaces the wall the same way.
396var corners = vec4<f32>(0.0);
397for (var corner = 0u; corner < 4u; corner += 1u) {{
398 let at = vec2<u32>(vec2<i32>(cell + vec2<f32>(f32(corner % 2u), f32(corner / 2u))));
399 var hash = at.x * 0x27d4eb2du + at.y * 0x165667b1u;
400 hash = (hash ^ (hash >> 15u)) * 0x2545f491u;
401 hash = hash ^ (hash >> 13u);
402 corners[corner] = f32(hash) * 2.3283064e-10;
403}}
404let top = mix(corners.x, corners.y, weighted.x);
405let bottom = mix(corners.z, corners.w, weighted.x);
406let rise = mix(top, bottom, weighted.y) * 2.0 - 1.0;
407let fade = (1.0 - smoothstep({x0:.4}, {x1:.4}, abs(world.x)))
408 * (1.0 - smoothstep({y0:.4}, {y1:.4}, abs(world.y - {center_height:.4})));
409world = world + vec3<f32>(0.0, 0.0, {amplitude:.4} * rise * fade);
410",
411 x0 = half_width * 0.80,
412 x1 = half_width * 0.99,
413 y0 = half_height * 0.72,
414 y1 = half_height * 0.99,
415 center_height = half_height,
416 )
417}