# Shader cookbook ## The override seam Shaders live in Rust (`libs/game/render/src/shaders.rs`, and the `DrawGame*` structs). Their *look* is overridden in the splash DSL by inheritance: ```rust script_mod! { use mod.prelude.widgets_internal.* use mod.widgets.* mod.widgets.MyViewBase = #(MyView::register_widget(vm)) mod.widgets.MyView = set_type_default() do mod.widgets.MyViewBase { width: Fill height: Fill draw_cube += { light_dir: vec3(0.35, 0.8, 0.45) } draw_alpha += { light_dir: vec3(0.35, 0.8, 0.45) } draw_terrain += { light_dir: vec3(0.35, 0.8, 0.45) } draw_skinned += { light_dir: vec3(0.35, 0.8, 0.45) } draw_models += { light_dir: vec3(0.35, 0.8, 0.45) } } } ``` The engine owns structure — where a vertex is, where a spark is, how long it lives. The override owns appearance. A generated or untrusted game can restyle freely without being able to break the simulation. Do not defeat this by moving gameplay constants into the DSL. **Set `light_dir` on every draw type at once.** A skinned character lit differently from its terrain is the most common unexplained-wrongness bug here. ## Vertex packing Vertex attributes in this engine are **f32-only**. There is no u8/u16/i16 attribute type. Compression means bit-packing into f32 lanes and unpacking in the shader. Builtins available on every backend (Metal / GLSL / HLSL / WGSL): ``` unpack2f16(x) -> vec2 // two half floats from one f32 lane unpack4u8(x) -> vec4 // four unorm bytes from one f32 lane ``` `geom.GameMeshVertex` (`draw/geometry_gen.rs`) is the shared packed layout: | field | packing | floats | | --- | --- | --- | | position | 3 × f32, exact | 3 | | normal | octahedral, 2 × f16 in one lane | 1 | | uv | 2 × f16 in one lane | 1 | | colour | 4 × unorm8 in one lane | 1 | | **total** | | **6 / 24 B** | `geom.VectorVertexPacked` is the house precedent. Use the shared layout; do not invent a fatter vertex for convenience. Octahedral normals: map the unit sphere to a square, store 2 components, reconstruct the third in-shader. Error is negligible for shading and it halves the cost against three f32s. ## Uniform vs instance The rule that produced the measured 27% instance saving: > If a value is identical for every instance in a batch, it is a **uniform**, not an instance field. `sun_color`, `sun_sky`, `sun_ground`, `fog_color` are uniforms. `fog_density` stayed per-instance only because shadows switch it off individually. Twelve floats of duplication per cube × every cube × every frame is what moving them saved. Check your width from the compiled shader, not by counting: ```rust stats.instance_floats // RenderStats ``` ## Common overrides **Palette tint per instance** — prefer `ModelInstance::with_tint(vec4)` over a shader variant. One shader, many looks. **Rim light** — cheap hero separation, the fastest route from silhouette 2 to 3: ``` rim = pow(1.0 - abs(dot(normal, view_dir)), 3.0); color += rim * rim_color * rim_strength; ``` **Hemispheric ambient** — the difference between "rendered" and "lit": ``` ambient = mix(sun_ground, sun_sky, normal.y * 0.5 + 0.5); ``` **Fog** — must match the sky at the horizon, or the world reads as pasted on a backdrop: ``` f = 1.0 - exp(-dist * fog_density); color = mix(color, fog_color, f); ``` **Firework look override** (the house precedent in `arcade_view.rs`): real fireworks are *symmetric*. Pick one uniform radial angle per spark, then add swirl and fizzle so sparks look like burning matter rather than points on a sphere. The engine shader keeps the ballistic arc; the override supplies the wobble. ## Cost guidance Per-pixel work multiplies by resolution; per-vertex by mesh density; per-instance by draw count. On Quest, bandwidth binds before ALU, so: - Move maths from fragment to vertex where the result is smooth across a triangle. - Prefer one more ALU op to one more texture fetch. - Branching is cheap when the whole warp agrees, expensive when it splits — branch on a uniform, not on a per-pixel value. - `pow` is not free; `x*x*x` is. Verify against `apps/arcade/BUDGETS.md` with `renderer.frame_p90_ms()` before and after.