Shader API: particles as coloured lights

[[particle_lights]] names particles, with a colour and reach, that join the frame's coloured lights
at their position, with their alpha as intensity. MaterialInput.colored_light gives those lights at
a point to the material stage, which could not read them before.

Fixes #6584

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
This commit is contained in:
TeiwazikandClaude Opus 5.5 committed 2026-11-02 00:44:36 +01:00
1 parent 3310fcafb9
commit ffba053a6a
11 files changed
+132 -4

No files matched your search

+4
View File
@@ -3974,6 +3974,10 @@ impl Renderer {
pub fn section_count(&self) -> usize {
self.sections.len()
}
/// The running native pack's `[[particle_lights]]`.
pub fn particle_lights(&self) -> Option<&[horizon_shader_api::manifest::ParticleLight]> {
self.native.as_ref().map(|n| n.particle_lights.as_slice())
}
/// Whether one of Horizon's own packs with `[shadow]` draws, whose shadow pass takes the
/// frame's `shadow_only` quads.
pub fn native_shadow(&self) -> bool {
@@ -145,7 +145,8 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent)).color;
in.face, fluid, in.emission, normal, tangent,
horizon_terrain_native_colored_light(in.position))).color;
}
var<private> horizon_material_face_normal: vec3<f32>;
struct HorizonMaterialSolid {
@@ -21,6 +21,8 @@ pub struct NativePack {
pub weather: Option<wgpu::RenderPipeline>,
/// The draw of the shadow map the stages read, if the pack asks for one.
pub shadow: Option<NativeShadow>,
/// `[[particle_lights]]`.
pub particle_lights: Vec<horizon_shader_api::manifest::ParticleLight>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<PassTiming>,
}
@@ -133,6 +135,7 @@ impl NativePack {
labels.extend(post.labels().into_iter().map(str::to_owned));
}
let pack = Self {
particle_lights: report.manifest.particle_lights.clone(),
frame,
sky,
material,
@@ -4093,6 +4093,47 @@ impl ParticleEngine {
}
}
/// The particles that `entries` name, as lights: where each is this frame, its entry's colour
/// as RGB and reach, and its alpha as the intensity.
pub fn lights(
&self,
entries: &[horizon_shader_api::manifest::ParticleLight],
partial: f32,
) -> Vec<([f64; 3], u32, f32, f32)> {
let shining: Vec<(ParticleType, u32, f32)> = entries
.iter()
.flat_map(|e| {
let [r, g, b] = e.color.map(|c| (c.clamp(0.0, 1.0) * 255.0) as u32);
e.particles.iter().filter_map(move |name| {
let name = if name.contains(':') {
name.clone()
} else {
format!("minecraft:{name}")
};
Some((
ParticleType::from_name(&name)?,
r << 16 | g << 8 | b,
e.reach,
))
})
})
.collect();
if shining.is_empty() {
return Vec::new();
}
let f = f64::from(partial);
self.particles
.iter()
.filter(|p| p.rgba[3] > 0.0)
.filter_map(|p| {
let (_, rgb, reach) = shining.iter().find(|(ty, ..)| *ty == p.ty)?;
let at =
std::array::from_fn(|i| p.previous[i] + (p.position[i] - p.previous[i]) * f);
Some((at, *rgb, *reach, p.rgba[3].clamp(0.0, 1.0)))
})
.collect()
}
/// Camera-facing quads (`QuadParticleRenderState.renderRotatedQuad`).
pub fn render(
&self,
@@ -16,8 +16,16 @@ impl Game<'_> {
camera: glam::DVec3,
lines: &mut Vec<gizmos::Line>,
) -> Vec<dynamic_light::Colored> {
let particle_lights = self
.renderer
.as_ref()
.and_then(|r| r.particle_lights())
.map_or_else(Vec::new, |entries| {
self.particles
.lights(entries, self.clock.camera_fraction() as f32)
});
let Some(player) = self.native_player() else {
return Vec::new();
return dynamic_light::select_colored(particle_lights.into_iter(), camera);
};
let mut rims = Vec::new();
let mut lights = Vec::new();
@@ -98,7 +106,9 @@ impl Game<'_> {
))
});
dynamic_light::select_colored(
sources.map(|l| (l.at, l.rgb, l.radius, l.intensity)),
sources
.map(|l| (l.at, l.rgb, l.radius, l.intensity))
.chain(particle_lights),
camera,
)
}
+1
View File
@@ -241,6 +241,7 @@ pub const MATERIAL_INPUT: Struct = Struct {
emission: "f32" = "How strongly the block itself shines, 0..1, by its kind of light: a redstone torch or a candle 7/15, a torch or lava 14/15, glowstone 1; 0 for most blocks.",
normal: "vec3<f32>" = "The surface's geometric normal, a unit vector in world axes, facing the camera. A normal within about 3.6 degrees of an axis is that axis exactly, so distant block faces do not shimmer; flowing water's slopes are steeper and keep theirs.",
tangent: "vec4<f32>" = "The surface's tangent: xyz the unit direction in world axes along which `uv.x` grows, w the sign (1 or -1) that makes `cross(normal, tangent.xyz) * tangent.w` the direction `uv.y` grows along (down the texture). With `normal` it turns a normal map's normals into world axes.",
colored_light: "vec3<f32>" = "The frame's coloured lights at this point (`[[particle_lights]]` and mods' visual lights), 0..1, to light the surface with as the pack likes; 0 where none reaches.",
},
};
+46
View File
@@ -24,6 +24,8 @@ pub const MAX_TEXTURES: usize = 8;
pub const MAX_TEXTURE_SIZE: u32 = 2048;
/// `[[lights]]` entries a manifest may declare.
pub const MAX_LIGHTS: usize = 256;
/// `[[particle_lights]]` entries a manifest may declare.
pub const MAX_PARTICLE_LIGHTS: usize = 32;
/// Sprite tags a pack may declare: the bits of `MaterialInput.tags`.
pub const MAX_TAGS: usize = 32;
@@ -62,6 +64,9 @@ pub struct Manifest {
/// The colour of block light by the block giving it off: the first entry with a matching
/// pattern wins (`Manifest::light_of`).
pub lights: Vec<LightColor>,
/// Particles that join the frame's coloured lights (`MaterialInput.colored_light`): the first
/// entry naming a particle wins.
pub particle_lights: Vec<ParticleLight>,
pub budget: Budget,
/// When the material stage's water mirror (`reflection`) is drawn.
pub reflection: Option<Reflection>,
@@ -123,6 +128,16 @@ pub struct PackTexture {
pub repeat: bool,
}
/// `[[particle_lights]]`: `particles` (names, as `firefly` or `minecraft:firefly`) light what's
/// within `reach` blocks in `color`, as strongly as they are opaque.
#[derive(Clone, Debug, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ParticleLight {
pub particles: Vec<String>,
pub color: [f32; 3],
pub reach: f32,
}
/// `[[lights]]`: the colour of the block light from some blocks, as the material stage reads
/// it through `block_light_color`.
#[derive(Clone, Debug, Deserialize, PartialEq)]
@@ -344,6 +359,8 @@ struct Raw {
#[serde(default)]
lights: Vec<LightColor>,
#[serde(default)]
particle_lights: Vec<ParticleLight>,
#[serde(default)]
budget: RawBudget,
reflection: Option<Reflection>,
shadow: Option<Shadow>,
@@ -650,6 +667,34 @@ impl Manifest {
"lights are read by the material stage: give it a [material]".into(),
));
}
if !raw.particle_lights.is_empty() && raw.material.is_none() {
return Err(err(
"particle lights are read by the material stage: give it a [material]".into(),
));
}
if raw.particle_lights.len() > MAX_PARTICLE_LIGHTS {
return Err(err(format!(
"{} [[particle_lights]]; a pack may declare {MAX_PARTICLE_LIGHTS}",
raw.particle_lights.len()
)));
}
for l in &raw.particle_lights {
if l.particles.is_empty() {
return Err(err("a [[particle_lights]] entry needs particles".into()));
}
if l.color.iter().any(|c| !(0.0..=1.0).contains(c)) {
return Err(err(format!(
"particle light color {:?} is not in 0..=1 per channel",
l.color
)));
}
if !(0.25..=16.0).contains(&l.reach) {
return Err(err(format!(
"particle light reach {} is not in 0.25..=16",
l.reach
)));
}
}
if raw.lights.len() > MAX_LIGHTS {
return Err(err(format!(
"{} [[lights]]; a pack may declare {MAX_LIGHTS}",
@@ -728,6 +773,7 @@ impl Manifest {
materials: raw.materials,
textures,
lights: raw.lights,
particle_lights: raw.particle_lights,
budget: Budget { gpu_ms },
reflection: raw.reflection,
shadow: raw.shadow,
+1 -1
View File
@@ -423,7 +423,7 @@ struct HorizonMaterialIn {
let normal = horizon_material_normal(in.world);
let tangent = horizon_material_tangent(in.world, in.uv, normal);
let input = MaterialInput(in.albedo, in.tint, in.light, in.lightmap, in.world, in.uv, in.translucent != 0u, in.tags, in.material,
in.position.xy / frame.resolution, in.face, fluid, in.emission, normal, tangent);
in.position.xy / frame.resolution, in.face, fluid, in.emission, normal, tangent, vec3<f32>(0.0));
return material(input).color;
}
",
+15
View File
@@ -647,3 +647,18 @@ fn materials_name_blocks_and_move_sprites_to_the_translucent_pass() {
);
assert!(m.translucent_layers(&[(0, 9)], 4).is_empty());
}
#[test]
fn reads_particle_lights_and_refuses_bad_ones() {
let entry = |rest: &str| {
format!(
"{}[[particle_lights]]\nparticles = [\"firefly\"]\n{rest}\n",
sample_text()
)
};
let m = Manifest::parse(&entry("color = [0.3, 0.35, 0.2]\nreach = 4.0")).unwrap();
assert_eq!(m.particle_lights[0].particles, ["firefly"]);
assert_eq!(m.particle_lights[0].reach, 4.0);
assert!(refused(&entry("color = [2.0, 0.0, 0.0]\nreach = 4.0")).contains("0..=1"));
assert!(refused(&entry("color = [1.0, 1.0, 1.0]\nreach = 40.0")).contains("reach"));
}
+1
View File
@@ -91,6 +91,7 @@ One point of a block's surface, for `fn material(input: MaterialInput) -> Surfac
| `emission` | `f32` | How strongly the block itself shines, 0..1, by its kind of light: a redstone torch or a candle 7/15, a torch or lava 14/15, glowstone 1; 0 for most blocks. |
| `normal` | `vec3<f32>` | The surface's geometric normal, a unit vector in world axes, facing the camera. A normal within about 3.6 degrees of an axis is that axis exactly, so distant block faces do not shimmer; flowing water's slopes are steeper and keep theirs. |
| `tangent` | `vec4<f32>` | The surface's tangent: xyz the unit direction in world axes along which `uv.x` grows, w the sign (1 or -1) that makes `cross(normal, tangent.xyz) * tangent.w` the direction `uv.y` grows along (down the texture). With `normal` it turns a normal map's normals into world axes. |
| `colored_light` | `vec3<f32>` | The frame's coloured lights at this point (`[[particle_lights]]` and mods' visual lights), 0..1, to light the surface with as the pack likes; 0 where none reaches. |
### `Surface`
+6
View File
@@ -114,6 +114,11 @@ wrap = "repeat" # optional: repeat (default) or clamp
color = [0.3, 0.7, 1.0] # each channel 0 to 4
blocks = ["soul_*"] # block names, any number of * each
[[particle_lights]] # 32 at most: these particles join the frame's coloured lights
particles = ["firefly"] # names, `firefly` or `minecraft:firefly`; the first entry naming one wins
color = [0.8, 0.9, 0.4] # each channel 0 to 1; the particle's alpha is its intensity
reach = 4.0 # blocks, 0.25 to 16; the 16 nearest lights shine, read by MaterialInput.colored_light
[[tags]] # 32 at most: bit i of MaterialInput.tags is the i-th tag
id = "glass"
sprites = ["block/glass", "block/*_stained_glass"] # sprite names, at most one * each
@@ -149,6 +154,7 @@ stages.
## Changelog
- 2026-11-02: API 0.1 (additions): `[[particle_lights]]` and `MaterialInput.colored_light`.
- 2026-11-01: API 0.1 (additions): `[[materials]]` and `MaterialInput.material`, an id by sprite,
and `[shadow] solid_materials`.
- 2026-11-01: API 0.1 (additions): entities cast into `shadow_map`, the player's own model in first person too, and the round