Merge pull request #11117 from Delete228/native-still-fluid-render-upstream

🤖 Agent: Native mods can draw a still-fluid volume in a block; the fluid shading follows Ponder's FluidRenderHelper (MIT, credited in THIRD_PARTY_NOTICES) and nothing changes without a mod calling it.
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-11-07 10:33:08 +01:00
commit 8a50b63daa
13 files changed
+612 -30

No files matched your search

+10
View File
@@ -1,5 +1,15 @@
# Third-party notices
## Catnip / Ponder
`crates/horizon-client-render/src/fluid_render.rs` translates the still-fluid face tiling and
UV rules of Catnip's `FluidRenderHelper` from Ponder 1.0.82 for Minecraft 1.21.1
(`net.createmod.ponder:ponder-neoforge:1.0.82+mc1.21.1`). Copyright (c) 2022 The Create Team,
licensed under MIT (`LICENSE_Ponder` in that artifact); the full license heads the Rust file.
The native renderer uses Horizon's own block-sprite arrays, pose and light packing. The
development-only oracle calls the unchanged helper from the player's local artifact. No
original classes, game shaders, textures or prepared resources are distributed here.
## wgpu-hal
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
@@ -0,0 +1,210 @@
// Still-fluid tiling follows Catnip/Ponder 1.0.82 FluidRenderHelper.renderTiledFace.
// Copyright (c) 2022 The Create Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
use horizon_core::geometry::Direction;
/// Controls a still-fluid box. The tint is stack ARGB, independent of biome tint.
#[derive(Clone, Copy, Debug)]
pub struct NativeFluidRenderOptions {
pub argb: Option<u32>,
pub render_bottom: bool,
/// TextureAtlasSprite.uvShrinkRatio: 4 / the largest stitched atlas dimension.
/// Explicit because Horizon's block sprites occupy arrays rather than a stitched atlas.
pub uv_shrink_ratio: f32,
}
/// A corner before the caller's pose, with UVs within the still sprite, and its outward normal.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FluidVertex {
pub pos: [f32; 3],
pub uv: [f32; 2],
pub normal: [i8; 3],
}
/// Preserve sky light and increase block light to the fluid's luminosity (native light packing).
pub fn fluid_light(light: u32, luminosity: u8) -> u32 {
(light & 0xf0) | (((light >> 8 & 0xf0).max(u32::from(luminosity.min(15)) * 16)) << 8)
}
/// Emit whole tiled quads, or refuse invalid/unreasonably large geometry before emitting anything.
/// Negative coordinates and exact integer boundaries retain the source's local UV fractions.
pub fn fluid_box(
bounds: [f32; 6],
options: NativeFluidRenderOptions,
mut emit: impl FnMut(FluidVertex),
) -> bool {
if !options.uv_shrink_ratio.is_finite()
|| !(0.0..=1.0).contains(&options.uv_shrink_ratio)
|| bounds
.iter()
.any(|x| !x.is_finite() || x.abs() > 1_048_576.0)
|| (0..3).any(|i| bounds[i] > bounds[i + 3])
{
return false;
}
let tiles =
std::array::from_fn::<_, 3, _>(|i| (bounds[i + 3].ceil() - bounds[i].floor()) as u64);
let quads = tiles[0] * tiles[1] * 2
+ tiles[1] * tiles[2] * 2
+ tiles[0] * tiles[2] * if options.render_bottom { 2 } else { 1 };
if quads > 65_536 {
return false;
}
let [x0, y0, z0, x1, y1, z1] = bounds;
for (side, face) in [
(Direction::Down, [x0, z0, x1, z1, y0]),
(Direction::Up, [x0, z0, x1, z1, y1]),
(Direction::North, [x0, y0, x1, y1, z0]),
(Direction::South, [x0, y0, x1, y1, z1]),
(Direction::West, [z0, y0, z1, y1, x0]),
(Direction::East, [z0, y0, z1, y1, x1]),
] {
if side != Direction::Down || options.render_bottom {
tiled_face(&mut emit, side, face, options.uv_shrink_ratio);
}
}
true
}
fn tiled_face(out: &mut impl FnMut(FluidVertex), side: Direction, face: [f32; 5], ratio: f32) {
let [a0, b0, a1, b1, plane] = face;
let positive = matches!(side, Direction::Up | Direction::South | Direction::East);
let shrink = ratio * 0.25;
let lerp = |v| v + shrink * (0.5 - v);
let mut a = a0;
while a < a1 {
let end_a = (a.floor() + 1.0).min(a1);
let (u0, u1) = if matches!(side, Direction::North | Direction::East) {
(end_a.ceil() - end_a, end_a.ceil() - a)
} else {
(a - a.floor(), end_a - a.floor())
};
let (u0, u1) = (lerp(u0), lerp(u1));
let mut b = b0;
while b < b1 {
let end_b = (b.floor() + 1.0).min(b1);
let (v0, v1) = if side == Direction::Up {
(b - b.floor(), end_b - b.floor())
} else {
(end_b.ceil() - end_b, end_b.ceil() - b)
};
let (v0, v1) = (lerp(v0), lerp(v1));
let positions = match side {
Direction::West | Direction::East => {
let (near, far) = if positive { (end_a, a) } else { (a, end_a) };
[
[plane, end_b, near],
[plane, b, near],
[plane, b, far],
[plane, end_b, far],
]
}
Direction::North | Direction::South => {
let (near, far) = if positive { (a, end_a) } else { (end_a, a) };
[
[near, end_b, plane],
[near, b, plane],
[far, b, plane],
[far, end_b, plane],
]
}
Direction::Down | Direction::Up => {
let (near, far) = if positive { (b, end_b) } else { (end_b, b) };
[
[a, plane, near],
[a, plane, far],
[end_a, plane, far],
[end_a, plane, near],
]
}
};
let normal = match side {
Direction::Down => [0, -1, 0],
Direction::Up => [0, 1, 0],
Direction::North => [0, 0, -1],
Direction::South => [0, 0, 1],
Direction::West => [-1, 0, 0],
Direction::East => [1, 0, 0],
};
for (pos, uv) in positions
.into_iter()
.zip([[u0, v0], [u0, v1], [u1, v1], [u1, v0]])
{
out(FluidVertex { pos, uv, normal });
}
b = end_b;
}
a = end_a;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_tiling_is_outward_and_does_not_inset_geometry() {
let bounds = [-1.25, 0.125, -0.25, 1.5, 1.125, 0.75];
let mut result = Vec::new();
assert!(fluid_box(
bounds,
NativeFluidRenderOptions {
argb: None,
render_bottom: false,
uv_shrink_ratio: 4.0 / 2048.0,
},
|v| result.push(v),
));
assert_eq!(result.len(), (4 * 2 * 2 + 2 * 2 * 2 + 4 * 2) * 4);
for quad in result.as_chunks::<4>().0 {
let [a, b, c, _] = quad.map(|v| glam::Vec3::from_array(v.pos));
assert!(
(b - a)
.cross(c - a)
.dot(glam::Vec3::from_array(quad[0].normal.map(f32::from)))
> 0.0
);
assert!(
quad.iter()
.all(|v| (0..3).all(|i| v.pos[i] >= bounds[i] && v.pos[i] <= bounds[i + 3]))
);
}
assert!(
result
.iter()
.any(|v| v.pos == [bounds[0], bounds[4], bounds[2]])
);
assert!(!fluid_box(
[0.0, 0.0, 0.0, 1_000.0, 1_000.0, 1_000.0],
NativeFluidRenderOptions {
argb: None,
render_bottom: true,
uv_shrink_ratio: 0.0,
},
|_| panic!("invalid geometry must fail before emitting"),
));
}
#[test]
fn luminosity_changes_only_block_light() {
assert_eq!(fluid_light(0x20a0, 15), 0xf0a0);
assert_eq!(fluid_light(0x25a5, 15), 0xf0a0);
assert_eq!(fluid_light(0xe030, 3), 0xe030);
}
}
+63 -7
View File
@@ -45,6 +45,7 @@ fn static_entity_shader() -> String {
mod crumbling_tests;
mod entity_batch;
mod entity_outline;
mod fluid;
#[cfg(test)]
mod inter_stage_tests;
#[cfg(test)]
@@ -53,9 +54,9 @@ mod weather;
mod world_text;
use entity_batch::EntityBatch;
pub use entity_batch::{
DISSOLVE_OVERLAY, DRAGON_RAYS_OVERLAY, ENERGY_SWIRL_OVERLAY, OIT_ENTITY, SHADOW_OVERLAY,
TEXT_POLYGON_OFFSET_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY,
DISSOLVE_OVERLAY, DRAGON_RAYS_OVERLAY, ENERGY_SWIRL_OVERLAY, FLUID_OVERLAY, OIT_ENTITY,
SHADOW_OVERLAY, TEXT_POLYGON_OFFSET_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY,
TRANSLUCENT_ITEM_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY,
WORLD_TEXT_POLYGON_OFFSET_OVERLAY, mark_oit,
};
pub use view::{View, ViewSet};
@@ -1205,6 +1206,7 @@ pub struct Renderer {
world_primitives: world_primitives::WorldPrimitives,
text_polygon_offset_pipeline: wgpu::RenderPipeline,
world_text_pipeline: wgpu::RenderPipeline,
fluid_pipeline: wgpu::RenderPipeline,
celestial_pipeline: wgpu::RenderPipeline,
celestial_buffer: wgpu::Buffer,
end_sky: end_sky::EndSky,
@@ -1240,6 +1242,8 @@ pub struct Renderer {
/// Ordinary entity models posed on the GPU; `None` without compute shaders.
entity_skin: Option<entity_skin::EntitySkin>,
group: wgpu::BindGroup,
fluid_group: wgpu::BindGroup,
fluid_sampler: wgpu::Sampler,
globals: wgpu::Buffer,
/// Resource-pack normal/specular maps, split like the colour sprite arrays.
pbr_maps: Option<Vec<[wgpu::Texture; 2]>>,
@@ -2771,6 +2775,13 @@ impl Renderer {
bind_group_layouts: &[Some(&bindings)],
..Default::default()
});
let fluid_pipeline = fluid::pipeline(
&device,
Some(&layout),
&shader,
config.format.remove_srgb_suffix(),
&sprite_constants,
);
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
let terrain_buffers = [
Some(wgpu::VertexBufferLayout {
@@ -3329,6 +3340,18 @@ impl Renderer {
&lightmap_sampler,
&emission_stats.view,
);
let fluid_sampler = device.create_sampler(&fluid::SAMPLER);
let fluid_group = terrain_group(
&device,
"Still fluid resources",
&bindings,
&globals,
&sprite_views,
&fluid_sampler,
&lightmap_view,
&lightmap_sampler,
&emission_stats.view,
);
let crosshair_group = terrain_group(
&device,
"Debug crosshair resources",
@@ -4718,6 +4741,7 @@ impl Renderer {
world_primitives,
text_polygon_offset_pipeline,
world_text_pipeline,
fluid_pipeline,
celestial_pipeline,
celestial_buffer,
end_sky: end_sky::EndSky::default(),
@@ -4750,6 +4774,8 @@ impl Renderer {
entity_batch: EntityBatch::default(),
entity_skin,
group,
fluid_group,
fluid_sampler,
globals,
pbr_maps,
lightmap,
@@ -7051,6 +7077,17 @@ impl Renderer {
&self.lightmap_sampler,
&self.emission_stats.view,
);
self.fluid_group = terrain_group(
&self.device,
"Still fluid resources",
&self.terrain_layout,
&self.globals,
&self.sprite_views,
&self.fluid_sampler,
&self.lightmap_view,
&self.lightmap_sampler,
&self.emission_stats.view,
);
}
/// Block sprite layers per array on this device: a sprite's array is its layer over it, its
/// layer in that array the rest (`block_sprites::Lookup`).
@@ -8692,6 +8729,7 @@ impl Renderer {
translucent_particle_quads,
translucent_item_quads,
oit_entity_quads,
fluid_quads,
world_text_quads,
world_text_offset_quads,
] = self.entity_batch.update(
@@ -8720,13 +8758,17 @@ impl Renderer {
+ translucent_particle_quads
+ translucent_item_quads
+ oit_entity_quads
+ fluid_quads
+ world_text_quads
+ world_text_offset_quads;
laps.lap("r_draw_lists");
// Where the tail of materials the classic and the improved passes
// both draw ends: the shadows, particles and items count back from it.
let base_quads =
world_quads - oit_entity_quads - world_text_quads - world_text_offset_quads;
let base_quads = world_quads
- oit_entity_quads
- fluid_quads
- world_text_quads
- world_text_offset_quads;
let native_clouds = self.native.as_ref().is_none_or(|n| n.vanilla_clouds);
let cloud_quads = if let Some(input) = frame.clouds.as_ref().filter(|_| native_clouds) {
let (rebuilt, uniform) =
@@ -10158,6 +10200,20 @@ impl Renderer {
}
pass.set_bind_group(0, &self.group, &[]);
}
// Still-fluid block entities draw as their own ordinary sorted
// translucent material, before terrain transparency. This material
// always writes depth and never joins the improved-transparency queues.
if fluid_quads > 0 {
pass.set_pipeline(&self.fluid_pipeline);
pass.set_bind_group(0, &self.fluid_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..fluid_quads * 6,
((base_quads + oit_entity_quads) * 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
}
if crumbling_quads > 0 && !oit_on {
pass.set_pipeline(&self.crumbling_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
@@ -10544,7 +10600,7 @@ impl Renderer {
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
let first = base_quads + oit_entity_quads;
let first = base_quads + oit_entity_quads + fluid_quads;
for (pipeline, count, offset) in [
(&self.world_text_pipeline, world_text_quads, first),
(
@@ -12604,7 +12660,7 @@ mod tests {
let mut batch = super::EntityBatch::default();
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8), false);
let vertices = &batch.combined;
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0]);
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0]);
assert_eq!(
vertices
.as_chunks::<4>()
@@ -14,8 +14,11 @@ use super::Vertex;
/// material at the end: the render types vanilla gives an `OitPipelineSet`
/// (translucent, emissive, eyes and breeze wind entity models). Off, the tag is
/// ignored and they stay in the first one, in their order.
/// Two native world-canvas text materials follow it, NORMAL then POLYGON_OFFSET.
const MATERIALS: usize = 12;
/// Sorted still-fluid geometry follows, then the two native world-canvas text materials,
/// NORMAL and POLYGON_OFFSET. Material storage order is independent of their draw stages.
const MATERIALS: usize = 13;
/// Sorted depth-writing still fluid, sampling terrain sprite arrays rather than the shared atlas.
pub const FLUID_OVERLAY: u32 = 41;
/// A bit of `Vertex::layer`, above the overlay (low 16 bits) and the HDR tier
/// (bits 16 to 23): the quad's render type has an `OitPipelineSet`
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
@@ -60,12 +63,15 @@ pub const WORLD_TEXT_POLYGON_OFFSET_OVERLAY: u32 = 40;
/// The material of a quad by its overlay (`MATERIALS` order): the overlay is the layer's low 16
/// bits, as entity.wgsl reads it; HDR output's tier rides in the high ones.
fn material(layer: u32, oit: bool) -> usize {
if layer & 0xffff == FLUID_OVERLAY {
return 10;
}
if oit && layer & OIT_ENTITY != 0 {
return 9;
}
match layer & 0xffff {
WORLD_TEXT_OVERLAY => 10,
WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 11,
WORLD_TEXT_OVERLAY => 11,
WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 12,
TEXT_POLYGON_OFFSET_OVERLAY => 1,
11 => 2,
12 => 3,
@@ -148,9 +154,9 @@ impl EntityBatch {
}
}
}
// RenderType.text sorts alpha-blended quads from far to near. Keep
// both world-canvas layers after existing materials (including eyes
// moved out of additive); front text uses polygon offset.
// Fluid and RenderType.text sort their own alpha-blended quads far to
// near. Fluid precedes both world-canvas layers, after existing materials
// (including eyes moved out of additive).
for runs in &self.runs[10..] {
let start = self.combined.len();
for &(first, count) in runs {
@@ -189,7 +195,7 @@ mod tests {
use super::*;
use std::{hint::black_box, time::Instant};
#[test]
fn world_canvas_sorts_after_oit_eyes_before_frame_tails() {
fn fluid_and_world_canvas_sort_after_oit_eyes_before_frame_tails() {
let quad = |layer, x| {
[Vertex {
layer,
@@ -204,6 +210,8 @@ mod tests {
(WORLD_TEXT_OVERLAY, 4.0),
(WORLD_TEXT_OVERLAY, 5.0),
(WORLD_TEXT_POLYGON_OFFSET_OVERLAY, 6.0),
(FLUID_OVERLAY, 11.0),
(FLUID_OVERLAY | OIT_ENTITY, 12.0),
]
.into_iter()
.flat_map(|(layer, x)| quad(layer, x))
@@ -215,12 +223,14 @@ mod tests {
let mut batch = EntityBatch::default();
assert_eq!(
batch.update(&entities, &additive, &quad(0, 9.0), &quad(0, 10.0), true),
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 1]
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 1]
);
let positions: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(
positions,
[1.0, 2.0, 3.0, 7.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0]
[
1.0, 2.0, 3.0, 7.0, 12.0, 11.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0
]
);
}
@@ -320,12 +330,12 @@ mod tests {
let mut batch = EntityBatch::default();
// Off: the tag changes nothing, the quads keep their place.
let counts = batch.update(&entities, &[], &[], &[], false);
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]);
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 1.0, 2.0, 4.0, 3.0]);
// On: the tagged quads come last, in order.
let counts = batch.update(&entities, &[], &[], &[], true);
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0]);
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0, 0]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]);
}
@@ -0,0 +1,69 @@
//! Sorted still-fluid material: ordinary back culling, alpha blend and color/depth writes.
pub(super) const SAMPLER: wgpu::SamplerDescriptor<'static> = wgpu::SamplerDescriptor {
label: Some("Still fluid (nearest texels, linear mipmaps)"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
mipmap_filter: wgpu::MipmapFilterMode::Linear,
lod_min_clamp: 0.0,
lod_max_clamp: 32.0,
compare: None,
anisotropy_clamp: 1,
border_color: None,
};
pub(super) fn pipeline(
device: &wgpu::Device,
layout: Option<&wgpu::PipelineLayout>,
shader: &wgpu::ShaderModule,
format: wgpu::TextureFormat,
constants: &[(&str, f64)],
) -> wgpu::RenderPipeline {
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Still fluid"),
layout,
vertex: wgpu::VertexState {
module: shader,
entry_point: Some("vs_native_fluid"),
compilation_options: wgpu::PipelineCompilationOptions {
constants,
..Default::default()
},
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_native_fluid"),
compilation_options: wgpu::PipelineCompilationOptions {
constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
}
@@ -454,9 +454,31 @@ impl PackEntities {
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
extend_without_portals(rest, &entities[..first.min(entities.len())]);
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
let mut without_fluids = Vec::new();
for (span, range) in runs(entities.len(), spans) {
let run_start = range.start as u32;
let whole = &entities[range];
// A native fluid carries sprite-array UVs, not shared-atlas UVs.
// Keep it in its own material even when a shader pack draws the
// model in the same block-entity run. Do not corrupt either UV space.
let whole: &[Vertex] = if whole
.chunks(4)
.any(|q| q[0].layer & 0xffff == super::FLUID_OVERLAY)
{
without_fluids.clear();
for quad in whole.chunks(4) {
if quad[0].layer & 0xffff == super::FLUID_OVERLAY {
if !span.hidden {
rest.extend_from_slice(quad);
}
} else {
without_fluids.extend_from_slice(quad);
}
}
&without_fluids
} else {
whole
};
// A block entity's run can hold End portal faces and beams beside plain parts (an End
// gateway: its cube and its beam): each goes to its own program.
// Beacon beams come in a run of their own, at stage NONE (game.rs: no stage of 5.4
@@ -1141,6 +1163,76 @@ fn skin_runs(skin: &super::entity_skin::SkinFrame) -> Vec<(EntitySpan, Range<u32
mod gpu_span_tests {
use super::*;
use crate::gfx::entity_skin::{SkinFrame, SkinJob};
#[test]
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
fn native_fluid_stays_native_while_model_uses_pack() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let layout = super::super::pack_draw::DrawUniforms::new(&device);
let mut geometry = PackEntities::new(&device, &layout.layout);
let mut mixed = vec![
Vertex {
layer: 0,
..bytemuck::Zeroable::zeroed()
};
8
];
for v in &mut mixed[4..] {
v.layer = super::super::FLUID_OVERLAY;
v.uv = [0.25, 0.75];
v.glint_uv = [17.0, 0.0];
}
let mut rest = Vec::new();
for hidden in [false, true] {
geometry.prepare(
&device,
&queue,
&layout.layout,
&mixed,
&[EntitySpan {
start: 0,
id: 77,
stage: render_stage("BLOCK_ENTITIES"),
hidden,
..Default::default()
}],
[true; PROGRAMS],
([0.; 4], [1; 2]),
Casters {
blocks: true,
..Default::default()
},
&mut rest,
None,
(&[], &[]),
);
assert_eq!(
geometry.scratch.len(),
4,
"the model still uses the shader pack"
);
assert_eq!(geometry.draws.len(), 1);
assert_eq!(
(geometry.draws[0].pack, geometry.draws[0].shadow),
(!hidden, true)
);
if hidden {
assert!(
rest.is_empty(),
"hidden native fluid must not leak into the visible pass"
);
} else {
assert_eq!(
bytemuck::cast_slice::<_, u8>(&rest),
bytemuck::cast_slice::<_, u8>(&mixed[4..]),
"fluid UV/layer payload cannot be sampled from the entity atlas"
);
}
}
}
#[test]
fn posed_draws_preserve_subject_ids_overlays_and_hidden_shadow_casters() {
let mut skin = SkinFrame {
+1
View File
@@ -43,6 +43,7 @@ pub mod effects;
pub mod environment;
pub mod extras;
pub mod fast_hash;
pub mod fluid_render;
pub mod fog;
pub mod font;
pub mod fusion;
@@ -723,6 +723,33 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
return apply_fog(color, in.spherical, in.cylindrical);
}
// Native still-fluid boxes use block sprites and lightmap without terrain AO/emission
// packing or entity-model diffuse shading. The absolute sprite layer occupies glint UV x.
struct NativeFluidOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
@location(2) @interpolate(flat) sprite: u32,
@location(3) spherical: f32,
@location(4) cylindrical: f32,
}
@vertex fn vs_native_fluid(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
@location(6) sprite: vec2<f32>) -> NativeFluidOut {
let block = max(f32(light.y), dynamic_block_light(position));
var lit = color * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
if globals.colored_count.x > 0u {
lit = vec4(min(lit.rgb + color.rgb * native_colored_light(position), vec3(1.0)), lit.a);
}
return NativeFluidOut(globals.view_proj * vec4(position, 1.0), uv, lit, u32(sprite.x),
length(position), max(length(position.xz), abs(position.y)));
}
@fragment fn fs_native_fluid(in: NativeFluidOut) -> @location(0) vec4<f32> {
// The source translucent shader has no alpha discard; transparent texels write depth too.
let color = sprite_grad(in.uv, in.sprite, dpdx(in.uv), dpdy(in.uv)) * in.color;
return apply_fog(color, in.spherical, in.cylindrical);
}
// Sky (SkyRenderer): the 10-vertex sky disc fan at y = 16 (radius 512) in
// the sky colour with sky fog (sky.fsh: fog 0..FogSkyEnd on the spherical
// distance, a step at FogSkyEnd on the cylindrical one), evaluated per pixel
+12 -11
View File
@@ -13,17 +13,18 @@ pub use horizon_client_render::{
block_entity_anim, block_entity_render, block_sprites, block_states, bluemap, bluemap_detect,
border_render, breaking, brewing, camera, chat, chunk, chunk_map, cit, cit_armor, cit_assets,
cit_glint, client_resources, clouds, collisions, command_block, containers, controller_ui,
crash, custom_sky, datapack, dynamic_light, effects, environment, extras, fast_hash, fog, font,
gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays,
inventory_effects, java_text, jigsaw_block, json_folder, level_loading, light, lod, logging,
menu_blur, mesher, minimap, mipmap, mod_browser, mod_list, model_library, models,
multiline_edit, native_emissive, options_file, options_fixes, options_ui, overlay,
pack_browser, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, screen_draw, screen_effects,
sculk, section_mask, server_terrain, settings, shader_options, shader_pack_list, signs, skins,
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles,
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
crash, custom_sky, datapack, dynamic_light, effects, environment, extras, fast_hash,
fluid_render, fog, font, gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud,
hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block, json_folder,
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_browser, mod_list,
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
options_ui, overlay, pack_browser, pack_screen, packs, pad_cursor, pad_glyph, par, particles,
poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, screen_draw,
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout,
split_map, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets,
world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
+1
View File
@@ -244,6 +244,7 @@ mod native_entity_motion;
mod native_entity_refs;
mod native_explosions;
mod native_falling;
mod native_fluid_render;
mod native_hooks;
mod native_host_bridge;
mod native_inventories;
@@ -0,0 +1,95 @@
//! Still-fluid geometry under the native block entity pose, with an ordinary translucent material.
use super::{Game, native_block_entity_draw::BlockEntityDraw};
use crate::{
fluid_render::{fluid_box, fluid_light},
gfx::FLUID_OVERLAY,
mesher::Vertex,
mod_api::{NativeFluidRenderOptions, NativeFluidStack},
};
use glam::Vec3;
#[allow(dead_code)]
impl Game<'_> {
/// Draw a still-fluid box under `draw`'s current pose. Supports water and lava with an empty
/// component patch; unknown fluid/component payloads return false before emitting geometry.
/// Empty accepted stacks return true without validating bounds. Nonempty stacks reject
/// invalid/oversized bounds before emitting geometry. The tint is stack ARGB (plain water
/// 0xff3f76e4, lava white), never biome water color; `options.argb` overrides it explicitly.
/// The actual animated block sprite is sampled with mipmaps. Faces are sorted, alpha blended,
/// back culled and write ordinary color/depth, with sky light retained and block luminosity
/// raised to the fluid's (lava 15). No model diffuse shade, geometry inset or alpha cutoff.
/// API @API:fluid_render@.
pub(super) fn native_draw_fluid_box(
&self,
draw: &mut BlockEntityDraw,
stack: &NativeFluidStack,
bounds: [f32; 6],
options: NativeFluidRenderOptions,
) -> bool {
let Some((sprite, tint, luminosity)) = fluid_visual(stack) else {
return false;
};
if stack.amount <= 0 {
return true;
}
let layers = &self.pack.render.sprite_layers;
let Some(&layer) = layers
.get(sprite)
.or_else(|| layers.get(sprite.strip_prefix("minecraft:").unwrap_or(sprite)))
else {
return false;
};
let pose = draw.pose();
let light = fluid_light(draw.light, luminosity);
let color = options.argb.unwrap_or(tint);
let color = (color & 0xff00_ff00) | ((color >> 16) & 0xff) | ((color & 0xff) << 16);
fluid_box(bounds, options, |v| {
draw.vertices_mut().push(Vertex {
pos: pose.transform_point3(Vec3::from_array(v.pos)).to_array(),
uv: v.uv,
layer: FLUID_OVERLAY,
color,
light,
// The source TRANSLUCENT shader does not use normals for lighting.
normal: [0; 4],
glint_uv: [layer as f32, 0.0],
});
})
}
}
fn fluid_visual(stack: &NativeFluidStack) -> Option<(&'static str, u32, u8)> {
if !stack.components.is_empty() {
return None;
}
match stack.fluid.as_str() {
"minecraft:water" => Some(("minecraft:block/water_still", 0xff3f76e4, 0)),
"minecraft:lava" => Some(("minecraft:block/lava_still", 0xffffffff, 15)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn component_payloads_and_unknown_fluids_are_not_guessed() {
let mut stack = NativeFluidStack {
fluid: "minecraft:water".into(),
amount: 1000,
components: Default::default(),
};
assert_eq!(
fluid_visual(&stack),
Some(("minecraft:block/water_still", 0xff3f76e4, 0))
);
stack.components.insert(
"!minecraft:custom_data".into(),
horizon_world::nbt::Tag::Compound(Default::default()),
);
assert!(fluid_visual(&stack).is_none());
stack.components.clear();
stack.fluid = "test:unknown".into();
assert!(fluid_visual(&stack).is_none());
}
}
+1
View File
@@ -19,6 +19,7 @@ pub use crate::crafting::{CustomRecipe, Ingredient};
pub use crate::extras::Corner;
pub use crate::extras_hud::Corners;
pub use crate::first_person::Hand;
pub use crate::fluid_render::NativeFluidRenderOptions;
pub use crate::fog::{FogData, FogType};
pub use crate::game::NativeChunkSnapshot;
pub use crate::game::NativeEffect;
@@ -0,0 +1,9 @@
{
"hooks": [],
"helpers": [
{"name":"draw_fluid_box", "target":"horizon_client::game::native_fluid_render::Game<'_>::native_draw_fluid_box", "mod_api":{}}
],
"rust_summary": "still-fluid boxes under a native draw pose, using sorted depth-writing translucency and the animated mipmapped block sprites (`native_draw_fluid_box`).",
"doc_summary": "`native_draw_fluid_box(draw,stack,bounds,options)` draws a `NativeFluidStack` in `[xMin,yMin,zMin,xMax,yMax,zMax]` under a block entity/mob draw pose. Supports `minecraft:water` and `minecraft:lava` with an empty component patch; unsupported IDs/patches, nonfinite/reversed bounds and boxes exceeding 65,536 tiled quads return false without partial geometry. Empty accepted amounts return true without validating bounds and emit nothing. `NativeFluidRenderOptions` requires `argb:Option<u32>` (stack tint override, default plain water 0xff3f76e4/lava white), `render_bottom:bool`, and `uv_shrink_ratio:f32` (0..1, source TextureAtlasSprite ratio 4 / the largest stitched atlas dimension; explicit because Horizon uses sprite arrays). Faces tile at integer boundaries with local UV fractions, no geometry inset. The still sprite follows resource packs/animations/mipmap settings; nearest texels use linear mip selection. Luminosity raises block light (lava 15), preserving sky light; vertex colors have no model diffuse shading. A separate material sorts far to near, alpha blends, culls backs and writes ordinary color/depth, with no alpha discard. It draws with block entities before terrain transparency, separately from improved-transparency queues and the world canvas. Bundles need \"hooks\": @API:fluid_render@.",
"comments": ["crates/horizon-client/src/game/native_fluid_render.rs"]
}