Skip to main content

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}