Material3D + PaletteRampMaterial
Material3D lets consumers bring their own WGSL fragment + uniform UBO without forking the pipeline. The runtime caches built pipelines keyed on (TypeId, output_format, msaa_samples) so the same material type doesn't recompile on every frame.
Trait shape
#![allow(unused)] fn main() { use bytemuck::{Pod, Zeroable}; use wisp_3d::Material3D; #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] struct MyUniform { tint: [f32; 4] } struct MyMaterial { tint: [f32; 4] } impl Material3D for MyMaterial { type Uniforms = MyUniform; fn wgsl_source() -> &'static str { include_str!("path/to/shader.wgsl") } fn uniforms(&self) -> MyUniform { MyUniform { tint: self.tint } } } }
The user-supplied WGSL must declare three bind groups: view_proj at group 0, model at group 1, and the material's own UBO at group 2. See crates/wisp-3d/shaders/material_palette.wgsl for the canonical shape.
PaletteRampMaterial — the 404 shader port
PaletteRampMaterial::engmanager_404() constructs with the five hex stops from not-found.js (#fe640b, #e64553, #ea76cb, #8839ef, #1e66f5) + a time_seconds knob for the time-dependent palette offset.
The fragment computes t = dot(local_position, vec3(0.95, 0.52, -0.38)) * 0.28 + 0.58 + sin(time * 0.24) * 0.04. Local-space — not world, not view. That means rotating the model rotates the palette WITH it (the colours stay glued to the geometry), which is the visual that ships in the THREE version. Don't switch to world-space coords for "neatness" — you'll lose the painterly effect.
What the shader does
- Five-stop palette ramp along the model-local diagonal.
- Warm-band overlay (peach pushed into the upper-frontal band).
- Fake directional lambert against
vec3(-0.25, 0.55, 0.78)(same light asRender3DPass's default). - Rim term (
pow(1 - |dot(n, +Z)|, 2)). - Value-noise grain in screen space, time-modulated.
The PaletteUniform carries the 5 RGBA stops + a vec4 time slot (packed for 16-byte alignment). 96 bytes; layout-tested.