mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
1 parent
3310fcafb9
commit
ffba053a6a
11 files changed
+132
-4
No files matched your search
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
",
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user