Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 81baa3e0cb Screen overlays: wrap texels with a select, not integer modulo
The camera overlays (the vignette every frame) read up to four texels a
pixel, each wrapped with two integer modulos. Intel GPUs emulate integer
division. Reducing the coordinate to one period first (fract, as the shader
did before) leaves every read at most one texel outside the sprite, so a
select wraps it. Same texels and weights; clamp-to-edge is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 00:55:22 +02:00
Ludovic MarieandClaude Opus 5.5 21baf0dc99 Screen overlays: blur and clamp from the texture's .png.mcmeta
TextureMetadataSection's blur and clamp choose how vanilla samples a
standalone texture (ReloadableTexture.setSampler). The atlas now keeps
each texture's flags and the screen-effect quads (vignette, pumpkin blur,
powder snow outline, underwater, equippable camera overlays) sample
their sprite rectangle linearly and with clamp or repeat as those flags
say, instead of always nearest and repeat.

Fixes #335

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 11:53:03 +02:00
5 changed files with 173 additions and 16 deletions

No files matched your search

@@ -222,6 +222,7 @@ impl Game<'_> {
hud_overlays::camera_overlays(size[0] as i32, size[1] as i32, &overlays, &mut scope);
self.screen_fx.build(&ScreenInputs {
sprites: &self.gui.atlas.sprites,
flags: &self.gui.atlas.flags,
aspect: size[0] as f32 / size[1].max(1) as f32,
partial,
block_overlay,
+1 -1
View File
@@ -1921,7 +1921,7 @@ impl Renderer {
source: wgpu::ShaderSource::Wgsl(include_str!("screen.wgsl").into()),
});
let screen_attributes =
wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Float32x4,3=>Float32x4];
wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Float32x4,3=>Float32x4,4=>Uint32];
let screen_vertex = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<crate::screen_effects::ScreenVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
+40 -3
View File
@@ -18,11 +18,35 @@ pub struct Rect {
pub w: u32,
pub h: u32,
}
/// `TextureMetadataSection`'s `blur` and `clamp`: how vanilla samples a
/// standalone texture (`ReloadableTexture.setSampler`: linear rather than
/// nearest filtering, clamp-to-edge rather than repeat addressing).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct TextureFlags {
pub blur: bool,
pub clamp: bool,
}
impl TextureFlags {
/// The `texture` section of a `.png.mcmeta` file, if it sets either flag.
pub fn from_mcmeta(json: &str) -> Option<Self> {
let meta: serde_json::Value = serde_json::from_str(json).ok()?;
let section = meta.get("texture")?;
let flag = |key| section.get(key).and_then(serde_json::Value::as_bool) == Some(true);
let flags = Self {
blur: flag("blur"),
clamp: flag("clamp"),
};
(flags != Self::default()).then_some(flags)
}
}
pub struct HudAtlas {
pub pixels: Vec<u8>,
pub width: u32,
pub height: u32,
pub sprites: HashMap<String, Rect>,
/// Sprites whose `.png.mcmeta` sets `blur` or `clamp`; the others
/// sample as vanilla's defaults (nearest, repeat).
pub flags: HashMap<String, TextureFlags>,
pub fire_animations: Vec<(Rect, crate::sprites::SpriteAnimation)>,
/// The default font (bitmap sheets in this atlas, unifont rasterized on demand).
pub font: crate::font::Font,
@@ -211,7 +235,7 @@ impl HudAtlas {
let s = p.to_string_lossy();
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
});
let decode_one = |path: &PathBuf| -> Option<(String, u32, u32, Vec<u8>)> {
let decode_one = |path: &PathBuf| -> Option<(PackedImage, Option<TextureFlags>)> {
let name = path
.strip_prefix(&textures)
.ok()?
@@ -226,11 +250,15 @@ impl HudAtlas {
} else {
pixels
};
(w <= 1024 && (h <= 512 || name.starts_with("font/"))).then_some((name, w, h, pixels))
let flags = fs::read_to_string(path.with_extension("png.mcmeta"))
.ok()
.and_then(|json| TextureFlags::from_mcmeta(&json));
(w <= 1024 && (h <= 512 || name.starts_with("font/")))
.then_some(((name, w, h, pixels), flags))
};
let threads = std::thread::available_parallelism().map_or(2, |n| n.get());
let chunk = files.len().div_ceil(threads).max(1);
let mut images: Vec<_> = std::thread::scope(|scope| {
let decoded: Vec<_> = std::thread::scope(|scope| {
let handles: Vec<_> = files
.chunks(chunk)
.map(|part| {
@@ -242,6 +270,14 @@ impl HudAtlas {
.flat_map(|h| h.join().unwrap_or_default())
.collect()
});
let mut flags = HashMap::new();
let mut images: Vec<PackedImage> = Vec::with_capacity(decoded.len());
for (image, image_flags) in decoded {
if let Some(f) = image_flags {
flags.insert(image.0.clone(), f);
}
images.push(image);
}
if full {
images.extend(atlas_sprites(root, &images));
}
@@ -337,6 +373,7 @@ impl HudAtlas {
width,
height,
sprites,
flags,
fire_animations,
font,
white,
+34 -7
View File
@@ -1,26 +1,53 @@
// Full-screen camera effects (screen_effects.rs): clip-space quads sampling
// the shared 2D atlas with repeat inside each sprite rectangle.
// Full-screen camera effects (screen_effects.rs): clip-space quads reading
// one sprite rectangle of the shared 2D atlas as if it were its own texture,
// with the sampler vanilla gives it (ReloadableTexture.setSampler: linear
// when `blur`, clamp-to-edge when `clamp`, else nearest and repeat).
@group(0) @binding(1) var atlas: texture_2d<f32>;
@group(0) @binding(2) var atlas_sampler: sampler;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) @interpolate(flat) rect: vec4<f32>,
@location(2) color: vec4<f32>,
@location(3) @interpolate(flat) sampling: u32,
}
@vertex fn vs_screen(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>,
@location(2) rect: vec4<f32>, @location(3) color: vec4<f32>) -> Out {
@location(2) rect: vec4<f32>, @location(3) color: vec4<f32>,
@location(4) sampling: u32) -> Out {
var out: Out;
out.clip = vec4(pos, 0.0, 1.0);
out.uv = uv;
out.rect = rect;
out.color = color;
out.sampling = sampling;
return out;
}
const SAMPLE_BLUR: u32 = 1u;
const SAMPLE_CLAMP: u32 = 2u;
// A texel of the sprite, addressed by repeat or clamp-to-edge. With repeat, `p` is at most one
// texel outside the sprite (`sample` reduces the coordinate first), so wrapping is a select: no
// integer division, which Intel GPUs emulate (it cost 0.85 ms a frame on the vignette on an HD 620).
fn texel(origin: vec2<i32>, size: vec2<i32>, p: vec2<i32>, clamp_edge: bool) -> vec4<f32> {
var q = select(select(p, p - size, p >= size), p + size, p < vec2(0));
if clamp_edge { q = clamp(p, vec2(0), size - 1); }
return textureLoad(atlas, origin + q, 0);
}
fn sample(in: Out) -> vec4<f32> {
let f = fract(in.uv);
let uv = in.rect.xy + f * (in.rect.zw - in.rect.xy);
return textureSampleLevel(atlas, atlas_sampler, uv, 0.0) * in.color;
let dims = vec2<f32>(textureDimensions(atlas));
let origin = vec2<i32>(round(in.rect.xy * dims));
let size = max(vec2<i32>(round((in.rect.zw - in.rect.xy) * dims)), vec2(1));
let clamp_edge = (in.sampling & SAMPLE_CLAMP) != 0u;
// Repeat: one period of the sprite, so every texel read is at most one outside it.
let p = select(fract(in.uv), in.uv, clamp_edge) * vec2<f32>(size);
if (in.sampling & SAMPLE_BLUR) == 0u {
return texel(origin, size, vec2<i32>(floor(p)), clamp_edge) * in.color;
}
// GL_LINEAR: the four texels around the sample point, weighted.
let c = p - 0.5;
let i = vec2<i32>(floor(c));
let t = c - floor(c);
let a = mix(texel(origin, size, i, clamp_edge), texel(origin, size, i + vec2(1, 0), clamp_edge), t.x);
let b = mix(texel(origin, size, i + vec2(0, 1), clamp_edge), texel(origin, size, i + vec2(1, 1), clamp_edge), t.x);
return mix(a, b, t.y) * in.color;
}
@fragment fn fs_screen(in: Out) -> @location(0) vec4<f32> {
let c = sample(in);
+97 -5
View File
@@ -4,7 +4,10 @@
//! `hud_overlays::camera_overlays` (vignette, pumpkin blur, powder-snow
//! outline, nether portal swirl). Positions are clip-space quads; textures
//! come from the shared 2D atlas and may repeat.
use crate::{hud::Rect, hud_layout::GuiDraw};
use crate::{
hud::{Rect, TextureFlags},
hud_layout::GuiDraw,
};
use std::collections::HashMap;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -24,6 +27,17 @@ pub struct ScreenVertex {
/// Atlas rectangle (u0, v0, u1, v1) of the sprite.
pub rect: [f32; 4],
pub color: [f32; 4],
/// How the sprite is sampled: [`SAMPLE_BLUR`] and [`SAMPLE_CLAMP`].
pub sampling: u32,
}
/// Linear filtering (`TextureMetadataSection.blur`), else nearest.
pub const SAMPLE_BLUR: u32 = 1;
/// Clamp-to-edge addressing (`TextureMetadataSection.clamp`), else repeat.
pub const SAMPLE_CLAMP: u32 = 2;
/// The sampling bits of a standalone texture's flags.
pub fn sampling(flags: TextureFlags) -> u32 {
let bit = |on: bool, bit: u32| if on { bit } else { 0 };
bit(flags.blur, SAMPLE_BLUR) | bit(flags.clamp, SAMPLE_CLAMP)
}
#[derive(Clone, Debug)]
pub struct ScreenQuad {
@@ -34,6 +48,8 @@ pub struct ScreenQuad {
/// What the camera sees this frame.
pub struct ScreenInputs<'a> {
pub sprites: &'a HashMap<String, Rect>,
/// `.png.mcmeta` sampling of the standalone textures among `sprites`.
pub flags: &'a HashMap<String, TextureFlags>,
pub aspect: f32,
pub partial: f32,
/// Particle sprite of the view-blocking block around the eye.
@@ -92,17 +108,23 @@ impl ScreenEffects {
pub fn build(&self, input: &ScreenInputs) -> Vec<ScreenQuad> {
let mut out = Vec::new();
let sprite = |name: &str| input.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
// Standalone textures (`ReloadableTexture.setSampler`) follow their
// metadata; atlas sprites (block overlay, fire, portal) sample
// nearest with repeat.
let texture_sampling = |name: &str| input.flags.get(name).copied().map_or(0, sampling);
let scale = hand_scale(input.aspect);
// Screen-effect quads live at view depth 0.5 in the hand projection.
let hand_quad = |corners: [[f32; 2]; 4],
uv: [[f32; 2]; 4],
rect: [f32; 4],
color: [f32; 4]| ScreenQuad {
color: [f32; 4],
sampling: u32| ScreenQuad {
vertices: std::array::from_fn(|i| ScreenVertex {
pos: [corners[i][0] * scale[0], corners[i][1] * scale[1]],
uv: uv[i],
rect,
color,
sampling,
}),
blend: Blend::Alpha,
};
@@ -117,6 +139,7 @@ impl ScreenEffects {
[[1.0, 1.0], [0.0, 1.0], [0.0, 0.0], [1.0, 0.0]],
rect,
[c, c, c, 1.0],
0,
));
}
if let Some((brightness, yaw, pitch)) = input.water
@@ -129,6 +152,7 @@ impl ScreenEffects {
uv,
rect,
[brightness, brightness, brightness, 0.1],
texture_sampling("misc/underwater"),
));
}
if input.on_fire
@@ -150,11 +174,12 @@ impl ScreenEffects {
[[1.0, 1.0], [0.0, 1.0], [0.0, 0.0], [1.0, 0.0]],
rect,
[1.0, 1.0, 1.0, alpha],
0,
));
}
}
// Hud camera overlays: GUI-space full-screen blits (v grows downwards).
let gui = |rect: [f32; 4], color: [f32; 4], blend: Blend| ScreenQuad {
let gui = |rect: [f32; 4], color: [f32; 4], blend: Blend, sampling: u32| ScreenQuad {
vertices: std::array::from_fn(|i| {
let p = [[-1.0f32, 1.0], [1.0, 1.0], [1.0, -1.0], [-1.0, -1.0]][i];
ScreenVertex {
@@ -162,6 +187,7 @@ impl ScreenEffects {
uv: [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]][i],
rect,
color,
sampling,
}
}),
blend,
@@ -188,7 +214,7 @@ impl ScreenEffects {
Blend::Alpha
};
if let Some(rect) = sprite(texture) {
out.push(gui(rect, rgba(*color), blend));
out.push(gui(rect, rgba(*color), blend, texture_sampling(texture)));
}
}
GuiDraw::AtlasSprite {
@@ -197,7 +223,7 @@ impl ScreenEffects {
..
} => {
if let Some(rect) = sprite(name) {
out.push(gui(rect, rgba(*color), Blend::Alpha));
out.push(gui(rect, rgba(*color), Blend::Alpha, 0));
}
}
_ => {}
@@ -243,8 +269,10 @@ mod tests {
},
);
let e = ScreenEffects::default();
let flags = HashMap::new();
let input = ScreenInputs {
sprites: &sprites,
flags: &flags,
aspect: 1.6,
partial: 0.0,
block_overlay: None,
@@ -255,5 +283,69 @@ mod tests {
let quads = e.build(&input);
assert_eq!(quads.len(), 1);
assert_eq!(quads[0].blend, Blend::Vignette);
assert_eq!(quads[0].vertices[0].sampling, 0);
}
#[test]
fn standalone_textures_sample_by_their_metadata() {
let rect = Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
};
let sprites: HashMap<_, _> = ["misc/vignette", "misc/underwater", "block/stone"]
.map(|name| (name.to_owned(), rect))
.into();
let blur = TextureFlags {
blur: true,
clamp: false,
};
let clamp = TextureFlags {
blur: false,
clamp: true,
};
let flags: HashMap<_, _> = [
("misc/vignette".to_owned(), blur),
("misc/underwater".to_owned(), clamp),
// Atlas sprites ignore the texture section.
("block/stone".to_owned(), blur),
]
.into();
let quads = ScreenEffects::default().build(&ScreenInputs {
sprites: &sprites,
flags: &flags,
aspect: 1.6,
partial: 0.0,
block_overlay: Some("block/stone".to_owned()),
water: Some((1.0, 0.0, 0.0)),
on_fire: false,
gui_overlays: &[crate::hud_overlays::vignette(320, 240, 1.0, 0.0)],
});
let sampling: Vec<_> = quads.iter().map(|q| q.vertices[0].sampling).collect();
assert_eq!(sampling, [0, SAMPLE_CLAMP, SAMPLE_BLUR]);
}
#[test]
fn mcmeta_texture_section() {
let parse = TextureFlags::from_mcmeta;
assert_eq!(
parse(r#"{"texture":{"blur":true}}"#),
Some(TextureFlags {
blur: true,
clamp: false
})
);
assert_eq!(
parse(r#"{"texture":{"clamp":true,"mipmap_strategy":"mean"}}"#),
Some(TextureFlags {
blur: false,
clamp: true
})
);
// Defaults (false, false) or no section: the default sampler.
assert_eq!(parse(r#"{"texture":{"blur":false}}"#), None);
assert_eq!(parse(r#"{"animation":{}}"#), None);
assert_eq!(parse("not json"), None);
}
}