Merge pull request #11118 from Delete228/native-model-material-stack-upstream

🤖 Agent: Native mods can draw terrain models with an explicit material (shade, UV shift); the shading follows Flywheel's (MIT, credited). Main merged in after #11117 landed; its test now builds faces with #11356's BakedFace::new (clippy -D warnings passes).
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-11-07 11:18:17 +01:00
commit d3319194cc
17 files changed
+843 -72

No files matched your search

+28
View File
@@ -226,3 +226,31 @@ THE SOFTWARE.
## Just Enough Items
The Recipes settings icon is from [Just Enough Items 31.17.0.108](https://github.com/mezz/JustEnoughItems) by mezz and contributors. It is distributed under the MIT license in `crates/horizon-client-render/src/jei_recipes/LICENSE.txt`.
## Flywheel native model directional shading
The two-light diffuse expression and transformed-normal policy in
`terrain.wgsl`'s native model entries follow Flywheel 1.0.6
(`internal/diffuse.glsl`, `instance/transformed.vert`, `internal/common.vert`).
No Flywheel classes, compiled shaders or original model/texture assets are bundled.
Copyright (c) 2021-2024 Jozufozu
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.
+10 -2
View File
@@ -34,6 +34,8 @@ pub struct ModelQuad {
/// NeoForge's `render_type` names.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RenderType {
/// A declared render type unknown to this baker; distinct from an absent default.
Unsupported,
Solid,
Cutout,
CutoutMipped,
@@ -234,7 +236,10 @@ fn bake_into(
skip_flat: bool,
output: &mut Vec<ModelQuad>,
) -> Result<()> {
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
let render_type = model
.render_type
.as_deref()
.map(|name| RenderType::by_name(name).unwrap_or(RenderType::Unsupported));
for element in model.elements() {
let element = element
.as_ref()
@@ -292,7 +297,10 @@ fn bake_obj(
transform: BlockModelTransform,
output: &mut Vec<ModelQuad>,
) -> Result<()> {
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
let render_type = model
.render_type
.as_deref()
.map(|name| RenderType::by_name(name).unwrap_or(RenderType::Unsupported));
for face in obj.faces.iter().filter(|f| !hidden.contains(&f.object)) {
let slot = match obj.materials.get(&face.material) {
Some(MtlTexture::Sprite(sprite)) => {
+17
View File
@@ -777,6 +777,23 @@ mod tests {
);
}
#[test]
fn an_unknown_declared_render_type_remains_distinct_from_absent() {
let json = LIGHTS.replace("minecraft:cutout", "fixture:custom_material");
let library = library(&[("block/custom", &json)]);
let model = library.resolve("block/custom").unwrap();
let quads = crate::bake::bake_block_model(
&model,
crate::bake::BlockModelTransform::new([0; 3], false),
)
.unwrap();
assert!(!quads.is_empty());
assert!(
quads
.iter()
.all(|q| q.render_type == Some(crate::bake::RenderType::Unsupported))
);
}
#[test]
fn render_type_comes_from_the_leaf_most_model_and_loses_its_prefix() {
let library = library(&[
("block/base", r#"{"render_type":"translucent"}"#),
+86 -5
View File
@@ -48,6 +48,7 @@ mod entity_outline;
mod fluid;
#[cfg(test)]
mod inter_stage_tests;
mod terrain_model;
#[cfg(test)]
pub(crate) mod test_gpu;
mod weather;
@@ -55,9 +56,10 @@ mod world_text;
use entity_batch::EntityBatch;
pub use entity_batch::{
DISSOLVE_OVERLAY, DRAGON_RAYS_OVERLAY, ENERGY_SWIRL_OVERLAY, FLUID_OVERLAY, OIT_ENTITY,
SHADOW_OVERLAY, TEXT_POLYGON_OFFSET_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY,
SHADOW_OVERLAY, TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY, TERRAIN_MODEL_CUTOUT_OVERLAY,
TERRAIN_MODEL_SOLID_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,
WORLD_TEXT_POLYGON_OFFSET_OVERLAY, is_native_terrain, mark_oit,
};
pub use view::{View, ViewSet};
@@ -1207,6 +1209,7 @@ pub struct Renderer {
text_polygon_offset_pipeline: wgpu::RenderPipeline,
world_text_pipeline: wgpu::RenderPipeline,
fluid_pipeline: wgpu::RenderPipeline,
terrain_model_pipelines: [wgpu::RenderPipeline; 3],
celestial_pipeline: wgpu::RenderPipeline,
celestial_buffer: wgpu::Buffer,
end_sky: end_sky::EndSky,
@@ -1244,6 +1247,8 @@ pub struct Renderer {
group: wgpu::BindGroup,
fluid_group: wgpu::BindGroup,
fluid_sampler: wgpu::Sampler,
terrain_model_group: wgpu::BindGroup,
terrain_model_sampler: wgpu::Sampler,
globals: wgpu::Buffer,
/// Resource-pack normal/specular maps, split like the colour sprite arrays.
pbr_maps: Option<Vec<[wgpu::Texture; 2]>>,
@@ -2784,6 +2789,13 @@ impl Renderer {
config.format.remove_srgb_suffix(),
&sprite_constants,
);
let terrain_model_pipelines = terrain_model::pipelines(
&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 {
@@ -3354,6 +3366,22 @@ impl Renderer {
&lightmap_sampler,
&emission_stats.view,
);
let terrain_model_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Native cutout model (unmipped)"),
lod_max_clamp: 0.0,
..fluid::SAMPLER
});
let terrain_model_group = terrain_group(
&device,
"Native cutout model resources",
&bindings,
&globals,
&sprite_views,
&terrain_model_sampler,
&lightmap_view,
&lightmap_sampler,
&emission_stats.view,
);
let crosshair_group = terrain_group(
&device,
"Debug crosshair resources",
@@ -4744,6 +4772,7 @@ impl Renderer {
text_polygon_offset_pipeline,
world_text_pipeline,
fluid_pipeline,
terrain_model_pipelines,
celestial_pipeline,
celestial_buffer,
end_sky: end_sky::EndSky::default(),
@@ -4778,6 +4807,8 @@ impl Renderer {
group,
fluid_group,
fluid_sampler,
terrain_model_group,
terrain_model_sampler,
globals,
pbr_maps,
lightmap,
@@ -7135,6 +7166,17 @@ impl Renderer {
&self.lightmap_sampler,
&self.emission_stats.view,
);
self.terrain_model_group = terrain_group(
&self.device,
"Native cutout model resources",
&self.terrain_layout,
&self.globals,
&self.sprite_views,
&self.terrain_model_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`).
@@ -8779,6 +8821,9 @@ impl Renderer {
fluid_quads,
world_text_quads,
world_text_offset_quads,
native_solid_quads,
native_cutout_quads,
native_cutout_mipped_quads,
] = self.entity_batch.update(
entities,
frame.additive,
@@ -8807,7 +8852,10 @@ impl Renderer {
+ oit_entity_quads
+ fluid_quads
+ world_text_quads
+ world_text_offset_quads;
+ world_text_offset_quads
+ native_solid_quads
+ native_cutout_quads
+ native_cutout_mipped_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.
@@ -8815,7 +8863,10 @@ impl Renderer {
- oit_entity_quads
- fluid_quads
- world_text_quads
- world_text_offset_quads;
- world_text_offset_quads
- native_solid_quads
- native_cutout_quads
- native_cutout_mipped_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) =
@@ -10247,6 +10298,36 @@ impl Renderer {
}
pass.set_bind_group(0, &self.group, &[]);
}
// Native terrain models use the block sprite arrays with their own
// alpha/mipmap policies and ordinary opaque depth. Storage follows
// the existing materials without moving their indices or frame tails.
let mut first =
world_quads - native_solid_quads - native_cutout_quads - native_cutout_mipped_quads;
for (i, count) in [
native_solid_quads,
native_cutout_quads,
native_cutout_mipped_quads,
]
.into_iter()
.enumerate()
{
if count > 0 {
pass.set_pipeline(&self.terrain_model_pipelines[i]);
pass.set_bind_group(
0,
if i == 1 {
&self.terrain_model_group
} else {
&self.fluid_group
},
&[],
);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..count * 6, (first * 4) as i32, 0..1);
}
first += count;
}
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.
@@ -12714,7 +12795,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, 0]);
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0]);
assert_eq!(
vertices
.as_chunks::<4>()
@@ -16,7 +16,20 @@ use super::Vertex;
/// ignored and they stay in the first one, in their order.
/// 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;
const MATERIALS: usize = 16;
pub const TERRAIN_MODEL_SOLID_OVERLAY: u32 = 42;
pub const TERRAIN_MODEL_CUTOUT_OVERLAY: u32 = 43;
pub const TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY: u32 = 44;
/// Native terrain-array UV/payloads must never enter a shared-atlas shader-pack pass.
pub fn is_native_terrain(layer: u32) -> bool {
matches!(
layer & 0xffff,
FLUID_OVERLAY
| TERRAIN_MODEL_SOLID_OVERLAY
| TERRAIN_MODEL_CUTOUT_OVERLAY
| TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY
)
}
/// 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
@@ -34,7 +47,9 @@ pub const DISSOLVE_OVERLAY: u32 = 38;
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
pub fn mark_oit(quads: &mut [Vertex]) {
for vertex in quads {
vertex.layer |= OIT_ENTITY;
if !is_native_terrain(vertex.layer) {
vertex.layer |= OIT_ENTITY;
}
}
}
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
@@ -63,6 +78,12 @@ 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 {
match layer & 0xffff {
TERRAIN_MODEL_SOLID_OVERLAY => return 13,
TERRAIN_MODEL_CUTOUT_OVERLAY => return 14,
TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY => return 15,
_ => {}
}
if layer & 0xffff == FLUID_OVERLAY {
return 10;
}
@@ -157,7 +178,7 @@ impl EntityBatch {
// 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..] {
for runs in &self.runs[10..13] {
let start = self.combined.len();
for &(first, count) in runs {
self.combined
@@ -175,6 +196,13 @@ impl EntityBatch {
distance(b).total_cmp(&distance(a))
});
}
// Native solid/cutout models are depth writing and keep their submission order.
for runs in &self.runs[13..] {
for &(first, count) in runs {
self.combined
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
}
}
if self.moved_additive == 0 {
self.combined.extend_from_slice(additive);
} else {
@@ -195,6 +223,56 @@ mod tests {
use super::*;
use std::{hint::black_box, time::Instant};
#[test]
fn native_models_keep_opaque_order_and_do_not_move_existing_materials_or_tails() {
let quad = |layer, x| {
[Vertex {
layer,
pos: [x, 0.0, 0.0],
..geometry(1, false)[0]
}; 4]
};
let entities: Vec<_> = [
(FLUID_OVERLAY, 10.0),
(WORLD_TEXT_OVERLAY, 11.0),
(TERRAIN_MODEL_SOLID_OVERLAY | 279 << 16, 3.0),
(TERRAIN_MODEL_SOLID_OVERLAY, 1.0),
(TERRAIN_MODEL_CUTOUT_OVERLAY, 4.0),
(TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY, 5.0),
(OIT_ENTITY, 6.0),
(0, 7.0),
]
.into_iter()
.flat_map(|(layer, x)| quad(layer, x))
.collect();
let mut batch = EntityBatch::default();
let counts = batch.update(
&entities,
&quad(0, 8.0),
&quad(0, 9.0),
&quad(0, 12.0),
true,
);
assert_eq!(counts, [1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 2, 1, 1]);
let xs: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(
xs,
[7.0, 6.0, 10.0, 11.0, 3.0, 1.0, 4.0, 5.0, 8.0, 9.0, 12.0]
);
assert_eq!(
batch.combined[16].layer,
TERRAIN_MODEL_SOLID_OVERLAY | 279 << 16
);
}
#[test]
fn tagging_oit_does_not_corrupt_a_native_models_sprite_index() {
let mut vertices = [geometry(1, false)[0]; 2];
vertices[0].layer = TERRAIN_MODEL_CUTOUT_OVERLAY | 17 << 16;
vertices[1].layer = 3;
mark_oit(&mut vertices);
assert_eq!(vertices[0].layer, TERRAIN_MODEL_CUTOUT_OVERLAY | 17 << 16);
assert_eq!(vertices[1].layer, 3 | OIT_ENTITY);
}
#[test]
fn fluid_and_world_canvas_sort_after_oit_eyes_before_frame_tails() {
let quad = |layer, x| {
[Vertex {
@@ -223,7 +301,7 @@ 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, 2, 1]
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 1, 0, 0, 0]
);
let positions: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(
@@ -330,12 +408,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, 0]);
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 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, 0]);
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 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, 2.0, 3.0, 1.0, 4.0]);
}
@@ -463,11 +463,11 @@ impl PackEntities {
// 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)
.any(|q| super::is_native_terrain(q[0].layer))
{
without_fluids.clear();
for quad in whole.chunks(4) {
if quad[0].layer & 0xffff == super::FLUID_OVERLAY {
if super::is_native_terrain(quad[0].layer) {
if !span.hidden {
rest.extend_from_slice(quad);
}
@@ -1172,63 +1172,70 @@ mod gpu_span_tests {
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"
for overlay in [
super::super::FLUID_OVERLAY,
super::super::TERRAIN_MODEL_SOLID_OVERLAY,
super::super::TERRAIN_MODEL_CUTOUT_OVERLAY,
super::super::TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY,
] {
let mut mixed = vec![
Vertex {
layer: 0,
..bytemuck::Zeroable::zeroed()
};
8
];
for v in &mut mixed[4..] {
v.layer = overlay | 279 << 16;
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,
(&[], &[]),
);
} 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"
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"
);
}
}
}
}
@@ -0,0 +1,66 @@
//! Opaque/cutout terrain-sheet native models, with ordinary culling and depth writes.
pub(super) fn pipelines(
device: &wgpu::Device,
layout: Option<&wgpu::PipelineLayout>,
shader: &wgpu::ShaderModule,
format: wgpu::TextureFormat,
sprite_constants: &[(&str, f64)],
) -> [wgpu::RenderPipeline; 3] {
std::array::from_fn(|i| {
let mut constants = sprite_constants.to_vec();
constants.push(("NATIVE_MODEL_ALPHA_CUTOFF", [0.0, 0.1, 0.5][i]));
// The ordinary Vertex ABI is unchanged. This material's private payload uses
// normal's four bytes for a float z, and glint UV for float normal x/y.
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Uint32,6=>Float32x2];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(
[
"Native model solid",
"Native model cutout",
"Native model cutout mipmapped",
][i],
),
layout,
vertex: wgpu::VertexState {
module: shader,
entry_point: Some("vs_native_model"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &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_model"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
})
}
+1
View File
@@ -58,6 +58,7 @@ pub mod hud_overlays;
pub mod inventory_effects;
pub mod java_text;
pub mod jigsaw_block;
pub mod terrain_model;
pub use horizon_world::json_folder;
pub mod level_loading;
pub mod light;
@@ -184,7 +184,7 @@ fn bake_resolved(
Some(RenderType::Translucent | RenderType::Tripwire) => (true, false),
Some(RenderType::Solid) => (false, true),
Some(RenderType::Cutout | RenderType::CutoutMipped) => (false, false),
None => (
None | Some(RenderType::Unsupported) => (
quad.material.force_translucent || sprites.translucent[layer as usize],
sprites.region_opaque(layer, &quad.uv),
),
@@ -750,6 +750,45 @@ struct NativeFluidOut {
return apply_fog(color, in.spherical, in.cylindrical);
}
// Native terrain models retain floating-point transformed normals. Fullbright still receives
// entity directional shading; the material's alpha test and mip policy are independent.
// Diffuse/normal policy: Flywheel 1.0.6, copyright2021-2024 Jozufozu (MIT);
// full permission/warranty notice in THIRD_PARTY_NOTICES.md.
override NATIVE_MODEL_ALPHA_CUTOFF: f32 = 0.0;
struct NativeModelOut {
@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,
@location(5) normal: vec3<f32>,
}
@vertex fn vs_native_model(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
@location(5) normal_z: u32, @location(6) normal_xy: vec2<f32>) -> NativeModelOut {
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 NativeModelOut(globals.view_proj * vec4(position, 1.0), uv, lit, layer >> 16u,
length(position), max(length(position.xz), abs(position.y)),
vec3(normal_xy, bitcast<f32>(normal_z)));
}
@fragment fn fs_native_model(in: NativeModelOut) -> @location(0) vec4<f32> {
var color = sprite_grad(in.uv, in.sprite, dpdx(in.uv), dpdy(in.uv)) * in.color;
if NATIVE_MODEL_ALPHA_CUTOFF > 0.0 && color.a < NATIVE_MODEL_ALPHA_CUTOFF { discard; }
if any(in.normal != vec3(0.0)) {
let light0 = normalize(vec3(0.2, 1.0, -0.7));
let light1 = normalize(vec3(-0.2, select(1.0, -1.0, globals.time.y > 0.5), 0.7));
let shade = min(1.0, (max(0.0, dot(light0, in.normal))
+ max(0.0, dot(light1, in.normal))) * 0.6 + 0.4);
color = vec4(color.rgb * shade, color.a);
}
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
@@ -0,0 +1,215 @@
//! Native terrain-sheet models with explicit material and UV policy.
use crate::{gfx, mesher::Vertex, models::catalog::BakedFace};
use glam::{Mat3, Mat4, Vec3};
use horizon_assets::bake::RenderType;
fn same_sprite(left: &str, right: &str) -> bool {
left.strip_prefix("minecraft:").unwrap_or(left)
== right.strip_prefix("minecraft:").unwrap_or(right)
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NativeModelMaterial {
/// Use each baked quad's material; unsupported translucent/tripwire quads fail the whole call.
#[default]
Model,
Solid,
Cutout,
CutoutMipped,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NativeModelShade {
/// Respect the baked quad's directional-shading flag.
#[default]
Model,
Off,
/// The level's two entity light directions, even at full block/sky light.
Entity,
}
/// Transform UVs only on the explicitly named original sprite, in unit-sprite coordinates.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NativeModelUvShift<'a> {
pub source_sprite: &'a str,
pub target_sprite: &'a str,
pub scale: [f32; 2],
pub offset: [f32; 2],
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NativeModelRenderOptions<'a> {
pub material: NativeModelMaterial,
pub shade: NativeModelShade,
pub uv_shift: Option<NativeModelUvShift<'a>>,
/// Optional compatibility policy applied toward each face's UV bounds center before shifting.
/// Zero retains the baked UVs. For an older JSON FaceBakery use its actual atlas shrink ratio;
/// OBJ loaders that do not shrink UVs must keep zero.
pub uv_shrink_ratio: f32,
/// RGB multiplier on quads with a tint index; other quads keep their baked color.
pub tint: u32,
}
impl Default for NativeModelRenderOptions<'_> {
fn default() -> Self {
Self {
material: NativeModelMaterial::Model,
shade: NativeModelShade::Model,
uv_shift: None,
uv_shrink_ratio: 0.0,
tint: 0xff_ffff,
}
}
}
fn overlay(face: &BakedFace, policy: NativeModelMaterial) -> Option<u32> {
use NativeModelMaterial as M;
Some(match policy {
M::Solid => gfx::TERRAIN_MODEL_SOLID_OVERLAY,
M::Cutout => gfx::TERRAIN_MODEL_CUTOUT_OVERLAY,
M::CutoutMipped => gfx::TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY,
M::Model => match face.quad.render_type.unwrap_or(RenderType::Solid) {
RenderType::Solid if !face.quad.material.force_translucent => {
gfx::TERRAIN_MODEL_SOLID_OVERLAY
}
RenderType::Cutout => gfx::TERRAIN_MODEL_CUTOUT_OVERLAY,
RenderType::CutoutMipped => gfx::TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY,
_ => return None,
},
})
}
/// A whole draw validates before its first vertex. No temporary geometry allocation, resource
/// scans, partial fallback or change to the ordinary native model helper.
pub fn draw_model(
faces: &[BakedFace],
pose: Mat4,
light: u32,
options: NativeModelRenderOptions<'_>,
sprite: impl Fn(&str) -> Option<u32>,
mut emit: impl FnMut(Vertex),
) -> bool {
if !pose.is_finite()
|| !options.uv_shrink_ratio.is_finite()
|| !(0.0..=1.0).contains(&options.uv_shrink_ratio)
{
return false;
}
let normal_pose = Mat3::from_mat4(pose).inverse().transpose();
if !normal_pose.is_finite() {
return false;
}
let shift = match options.uv_shift {
Some(shift) => {
if shift
.scale
.iter()
.chain(&shift.offset)
.any(|v| !v.is_finite())
{
return false;
}
let (Some(source), Some(target)) =
(sprite(shift.source_sprite), sprite(shift.target_sprite))
else {
return false;
};
if source > u16::MAX.into() || target > u16::MAX.into() {
return false;
}
if !faces
.iter()
.any(|face| same_sprite(&face.quad.material.sprite, shift.source_sprite))
{
return false;
}
Some((source, target, shift))
}
None => None,
};
let vertices = |face: &BakedFace| -> Option<[Vertex; 4]> {
let quad = &face.quad;
let overlay = overlay(face, options.material)?;
let mut layer = sprite(&quad.material.sprite)?;
if layer > u16::MAX.into() || layer != face.layer {
return None;
}
let mut uv = quad.uv;
if uv.iter().flatten().any(|v| !v.is_finite()) {
return None;
}
if options.uv_shrink_ratio != 0.0 {
let center = std::array::from_fn::<_, 2, _>(|i| {
let min = uv.iter().map(|p| p[i]).fold(f32::INFINITY, f32::min);
let max = uv.iter().map(|p| p[i]).fold(f32::NEG_INFINITY, f32::max);
(min + min + max + max) / 4.0
});
for p in &mut uv {
*p = std::array::from_fn(|i| p[i] + options.uv_shrink_ratio * (center[i] - p[i]));
}
}
if let Some((_, target, shift)) = shift
&& same_sprite(&quad.material.sprite, shift.source_sprite)
{
layer = target;
for p in &mut uv {
*p = std::array::from_fn(|i| p[i] * shift.scale[i] + shift.offset[i]);
}
}
let shaded = match options.shade {
NativeModelShade::Model => quad.shade,
NativeModelShade::Off => false,
NativeModelShade::Entity => true,
};
let normal = if shaded {
let p = quad.positions.map(Vec3::from_array);
let n = (p[1] - p[0]).cross(p[2] - p[0]).normalize_or_zero();
// Baked model normals are signed bytes before the instance's normal matrix.
// Keep the transformed normal as floats; quantizing it again changes rotated faces.
let n = n.map(|v| ((v * 127.0) as i8) as f32 / 127.0);
(normal_pose * n).normalize_or_zero()
} else {
Vec3::ZERO
};
let normal_bits = normal.z.to_ne_bytes().map(|b| b as i8);
let argb = quad.color.map_or(u32::MAX, |v| v as u32);
let mut color = (argb & 0xff00_ff00) | (argb >> 16 & 0xff) | (argb & 0xff) << 16;
if quad.tint_index >= 0 {
let tint = options.tint;
let rgb = (tint >> 16 & 0xff) | (tint & 0xff00) | (tint & 0xff) << 16;
color = (color & 0xff00_0000)
| ((color & 0xff) * (rgb & 0xff) / 255)
| (((color >> 8 & 0xff) * (rgb >> 8 & 0xff) / 255) << 8)
| (((color >> 16 & 0xff) * (rgb >> 16 & 0xff) / 255) << 16);
}
let light = (light & 0xff).max(u32::from(quad.light[1]) * 16)
| ((light >> 8 & 0xff).max(u32::from(quad.light[0].max(quad.light_emission)) * 16)
<< 8);
let positions = quad
.positions
.map(|p| pose.transform_point3(Vec3::from_array(p)));
if positions.iter().any(|p| !p.is_finite()) || uv.iter().flatten().any(|v| !v.is_finite()) {
return None;
}
Some(std::array::from_fn(|i| Vertex {
pos: positions[i].to_array(),
uv: uv[i],
layer: overlay | layer << 16,
color,
light,
normal: normal_bits,
glint_uv: [normal.x, normal.y],
}))
};
if faces.iter().any(|face| vertices(face).is_none()) {
return false;
}
for face in faces {
for vertex in vertices(face).expect("validated immutable model") {
emit(vertex);
}
}
true
}
#[cfg(test)]
#[path = "terrain_model_tests.rs"]
mod tests;
@@ -0,0 +1,171 @@
use super::*;
use horizon_assets::{
bake::bake_flat_face,
model::{Direction, Material},
};
fn face() -> BakedFace {
BakedFace::new(
bake_flat_face(
[0.0; 3],
[16.0; 3],
[0.0, 0.0, 16.0, 16.0],
Direction::South,
Material {
sprite: "test:source".into(),
force_translucent: false,
},
-1,
)
.unwrap(),
7,
true,
false,
None,
)
}
fn sprite(name: &str) -> Option<u32> {
match name {
"test:source" => Some(7),
"test:target" => Some(11),
_ => None,
}
}
fn draw(
faces: &[BakedFace],
pose: Mat4,
options: NativeModelRenderOptions<'_>,
) -> (bool, Vec<Vertex>) {
let mut output = Vec::new();
let accepted = draw_model(faces, pose, 0xf0f0, options, sprite, |v| output.push(v));
(accepted, output)
}
#[test]
fn late_unknown_material_or_sprite_rejects_the_whole_model() {
let first = face();
let mut second = face();
second.quad.render_type = Some(RenderType::Unsupported);
let mut faces = [first, second];
let (accepted, output) = draw(&faces, Mat4::IDENTITY, Default::default());
assert!(!accepted);
assert!(output.is_empty());
let (accepted, output) = draw(
&faces,
Mat4::IDENTITY,
NativeModelRenderOptions {
material: NativeModelMaterial::Cutout,
..Default::default()
},
);
assert!(accepted);
assert_eq!(output.len(), 8);
faces[1].quad.material.sprite = "test:missing".into();
let (accepted, output) = draw(&faces, Mat4::IDENTITY, Default::default());
assert!(!accepted);
assert!(output.is_empty());
}
#[test]
fn exact_sprite_shift_and_explicit_json_shrink_precede_array_mapping() {
let options = NativeModelRenderOptions {
uv_shrink_ratio: 4.0 / 2048.0,
uv_shift: Some(NativeModelUvShift {
source_sprite: "test:source",
target_sprite: "test:target",
scale: [0.5; 2],
offset: [0.25, 0.125],
}),
..Default::default()
};
let (accepted, output) = draw(&[face()], Mat4::IDENTITY, options);
assert!(accepted);
assert_eq!(output[0].uv, [0.25048828, 0.12548828]);
assert_eq!(output[2].uv, [0.7495117, 0.6245117]);
assert!(output.iter().all(|v| v.layer >> 16 == 11));
let (accepted, output) = draw(
&[face()],
Mat4::IDENTITY,
NativeModelRenderOptions {
uv_shift: Some(NativeModelUvShift {
source_sprite: "test:target",
target_sprite: "test:source",
..options.uv_shift.unwrap()
}),
..options
},
);
assert!(!accepted);
assert!(output.is_empty());
}
#[test]
fn transformed_slope_normal_stays_float_and_fullbright_keeps_shading() {
let mut face = face();
face.quad.positions = [
[0.0, 0.0, 0.0],
[0.0, 1.0, 1.0],
[1.0, 1.0, 1.0],
[1.0, 0.0, 0.0],
];
let pose = Mat4::from_rotation_y(0.372) * Mat4::from_scale(Vec3::new(-2.0, 3.0, 1.0));
let (accepted, output) = draw(&[face], pose, Default::default());
assert!(accepted);
let normal = Vec3::new(
output[0].glint_uv[0],
output[0].glint_uv[1],
f32::from_ne_bytes(output[0].normal.map(|v| v as u8)),
);
assert!((normal.length() - 1.0).abs() < 1e-6);
assert!((normal.y - 0.31622776).abs() < 1e-6);
assert!((normal.x + 0.3448316).abs() < 1e-5, "{normal:?}");
assert_eq!(output[0].light, 0xf0f0);
assert_eq!(output[0].color, u32::MAX);
// Float payload, not a second signed-byte normal quantization.
assert_ne!(normal.y, (normal.y * 127.0) as i8 as f32 / 127.0);
}
#[test]
fn invalid_shift_and_singular_pose_emit_nothing() {
let options = NativeModelRenderOptions {
uv_shrink_ratio: f32::NAN,
..Default::default()
};
let (ok, output) = draw(&[face()], Mat4::IDENTITY, options);
assert!(!ok);
assert!(output.is_empty());
let (ok, output) = draw(
&[face()],
Mat4::from_scale(Vec3::new(1.0, 0.0, 1.0)),
Default::default(),
);
assert!(!ok);
assert!(output.is_empty());
}
#[test]
fn a_deduplicated_array_layer_is_not_the_same_named_source_sprite() {
let mut alias = face();
alias.quad.material.sprite = "test:alias".into();
let mut output = Vec::new();
assert!(draw_model(
&[face(), alias],
Mat4::IDENTITY,
0xf0f0,
NativeModelRenderOptions {
uv_shift: Some(NativeModelUvShift {
source_sprite: "test:source",
target_sprite: "test:target",
scale: [0.5; 2],
offset: [0.25; 2]
}),
..Default::default()
},
|name| if name == "test:alias" {
Some(7)
} else {
sprite(name)
},
|v| output.push(v)
));
assert_eq!(output[0].layer >> 16, 11);
assert_eq!(output[4].layer >> 16, 7);
assert_eq!(output[4].uv, [0.0, 0.0]);
assert_eq!(output[6].uv, [1.0, 1.0]);
}
+2 -2
View File
@@ -23,8 +23,8 @@ pub use horizon_client_render::{
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,
terrain_model, 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
@@ -279,6 +279,7 @@ mod native_scene;
mod native_server;
mod native_shaders;
mod native_structures;
mod native_terrain_model;
pub mod native_text_field;
mod native_ticks;
mod native_travel;
@@ -0,0 +1,41 @@
//! Explicit terrain-sheet material/shading/UV policy for a native standalone model.
use super::{Game, native_block_entity_draw::BlockEntityDraw};
use crate::mod_api::NativeModelRenderOptions;
#[allow(dead_code)]
impl Game<'_> {
/// Draw a named standalone model using block sprite arrays and normal cull/depth writes.
/// Model materials accept solid, cutout (.1 alpha cutoff, no mipmaps) and cutout-mipped
/// (.5 cutoff, mipmaps); unknown materials/sprites or invalid UV/pose data fail before
/// any geometry. An explicit material overrides the model's selection. Directional
/// shading remains independent of light: fullbright shaded models remain shaded.
/// UV shifts affect only the exact source sprite; a missing source match fails the
/// whole call. Optional shrink is caller-specified, never inferred from an atlas guess.
/// API @API:terrain_model@.
pub(super) fn native_draw_terrain_model(
&self,
draw: &mut BlockEntityDraw,
model: &str,
options: NativeModelRenderOptions<'_>,
) -> bool {
let Some(model) = self.pack.render.model_catalog.standalone(model) else {
return false;
};
let pose = draw.pose();
let light = draw.light;
let layers = &self.pack.render.sprite_layers;
crate::terrain_model::draw_model(
&model.faces,
pose,
light,
options,
|name| {
layers
.get(name)
.or_else(|| layers.get(name.strip_prefix("minecraft:").unwrap_or(name)))
.copied()
},
|vertex| draw.vertices_mut().push(vertex),
)
}
}
+3
View File
@@ -62,6 +62,9 @@ pub use crate::recipe_book::RecipeBookCategory;
pub use crate::sound_engine::SoundPlayback;
/// A named step of the server tick and its wall and CPU milliseconds (`native_server_tick_timed`).
pub use crate::spans::Span as TickSpan;
pub use crate::terrain_model::{
NativeModelMaterial, NativeModelRenderOptions, NativeModelShade, NativeModelUvShift,
};
pub use crate::text::Text;
pub use crate::world_primitives::{PrimitiveShape, WorldPrimitive, WorldPrimitives};
pub use horizon_assets::item_model::{DisplayContext, StackView};
@@ -0,0 +1,15 @@
{
"hooks": [],
"helpers": [
{
"name": "draw_terrain_model",
"target": "horizon_client::game::native_terrain_model::Game<'_>::native_draw_terrain_model",
"mod_api": {}
}
],
"rust_summary": "explicit terrain-sheet native model materials, shading and UV shifts.",
"doc_summary": "`native_draw_terrain_model(draw,model,NativeModelRenderOptions)` draws a standalone model with animated block sprite arrays. Material `Model` respects solid/cutout/cutout_mipped quads, or explicitly select `Solid`, `Cutout` or `CutoutMipped`. Cutout uses alpha .1 without mipmaps; mipmapped cutout uses .5. All cull backs and use ordinary opaque color/depth writes. `NativeModelShade::{Model,Off,Entity}` controls directional shading independently of light, including fullbright. `NativeModelUvShift` names original/target sprites and unit-sprite scale/offset; invalid/missing mappings fail before geometry. `uv_shrink_ratio` defaults zero and explicitly supports older FaceBakery UV policy without guessing atlas dimensions. `tint` defaults white and multiplies only tinted quads. Ordinary native model/fluid/Canvas/OIT behavior is unchanged. Bundles need \"hooks\": @API:terrain_model@.",
"comments": [
"crates/horizon-client/src/game/native_terrain_model.rs"
]
}