the minimap's black lines close out, and shared water cells draw once
- the Stormwind/Orgrimmar minimap lines are gone, at the cause: a group's boundary tile is authored 1.0 yd larger on that side alone, so touching groups overlap by 2 yd — without the bleed a LINEAR-filtered silhouette loses ≈0.38 texel at every abutment and the black clear reads through as a dashed line; with it, disc black goes 4.49% → 3.69% and no lines, inside the reference's own band - the tile sampler stays LINEAR but filters in gamma space (`GL_SKIP_DECODE_EXT`, as the reference's trace has it) — a terrain underlay and a NEAREST sampler both reproduced the symptom and were both retracted as wrong once wow-re refuted them - the canal-surface line in the world was a second defect: Stormwind's canal groups genuinely overlap (14,416 lattice points claimed wet twice), so the translucent sheet composited twice — the MLIQ `0x80` shared-tile gate now resolves each shared cell to one owner at load, pixel-identical and zero double-claimed points, not one wet point lost - the interior composite is probeable headless now: `WOW_MM_PROBE` synthesises the minimap slot server-less, and an offline rebuilder reproduces the defect and the fix from the shipped art Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,14 @@ const ZOOM_CHUNKS: [f32; 6] = [14.0, 12.0, 10.0, 8.0, 6.0, 4.0];
|
||||
/// the field through a computed pointer. Superseded in wow-re; do not "restore" a constant here.
|
||||
const INTERIOR_ZOOM_RADIUS: [f32; 6] = [150.0, 120.0, 90.0, 60.0, 40.0, 25.0];
|
||||
|
||||
/// The **outer-edge bleed**: a minimap tile on the boundary of its group's grid is drawn 1.0 yd
|
||||
/// larger on that side, so a group's art extends 1 yd past its bbox all the way round and two
|
||||
/// groups whose boxes touch overlap by 2 yd. Interior cell edges are shared exactly. Byte-verified
|
||||
/// (`0x6a549e`…`0x6a54db`, the constant `0xca8098` built in the emitter as `0.5 + 0.5`) and fitted
|
||||
/// to the reference's captured quads with zero error — wow-re
|
||||
/// `system/minimap/scratch/wmo-interior-no-adt-underlay.md` §8.
|
||||
const EDGE_BLEED_YD: f32 = 1.0;
|
||||
|
||||
/// The client's half-texel UV inset, as the quad scale that reproduces it: a tile spanning
|
||||
/// `extent` yards is baked at [`YD_PER_TEXEL`](benilla_assets::minimap_grid::YD_PER_TEXEL), so it
|
||||
/// is `W = extent / 0.5` texels wide, and mapping texel *centres* to the quad's edges instead of
|
||||
@@ -146,12 +154,37 @@ fn load_tile(asset_server: &AssetServer) -> impl Fn(&str) -> Handle<Image> + '_
|
||||
asset_server.load_with_settings(
|
||||
format!("mpq://textures/Minimap/{hash}"),
|
||||
|s: &mut benilla_assets::BlpLoaderSettings| {
|
||||
s.variant = benilla_assets::BlpVariant::Sprite;
|
||||
s.variant = benilla_assets::BlpVariant::MapTile;
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The **headless probe's** minimap widget. `WOW_MM_PROBE` drops the player inside a building in a
|
||||
/// server-less capture ([`crate::capture`]), but the real slot comes from the FrameXML `<Minimap>`
|
||||
/// extraction, which needs a logged-in UI — so without this the interior branch never runs and the
|
||||
/// composite cannot be looked at offline. Under the probe (and only then) we synthesise the slot the
|
||||
/// script would have published: a square in the top-right corner at the client's default zoom.
|
||||
///
|
||||
/// This is the instrument the B141 arc needed and did not have — the interior composite was being
|
||||
/// argued about from screenshots because nothing could render it without a server.
|
||||
fn probe_minimap_widget(mut widget: ResMut<MinimapWidget>, windows: Query<&Window>) {
|
||||
if widget.0.is_some() || std::env::var("WOW_MM_PROBE").is_err() {
|
||||
return;
|
||||
}
|
||||
let Ok(win) = windows.single() else { return };
|
||||
let side = (win.height() * 0.22).min(win.width() * 0.22);
|
||||
let margin = side * 0.15;
|
||||
let min = Vec2::new(win.width() - side - margin, margin);
|
||||
widget.0 = Some(MinimapSlot {
|
||||
rect: Rect::from_corners(min, min + Vec2::splat(side)),
|
||||
z: u64::MAX / 2,
|
||||
zoom: 3,
|
||||
inside_zoom: 3,
|
||||
alpha: 1.0,
|
||||
});
|
||||
}
|
||||
|
||||
/// This frame's extracted `<Minimap>` widget slot, written by `ui_script::extract::drive_script` (the
|
||||
/// `QuadContent::Minimap` arm) — `None` when no Minimap widget is visible (cluster hidden, no XML).
|
||||
#[derive(Resource, Default)]
|
||||
@@ -501,9 +534,36 @@ fn emit_minimap(
|
||||
let mid_z = 0.5 * (gn.bbox_min[2] + gn.bbox_max[2]);
|
||||
for col in 0..nx {
|
||||
for row in 0..ny {
|
||||
let mcx = gn.bbox_min[0] + (col as f32 + 0.5) * tw_x;
|
||||
let mcy = gn.bbox_min[1] + (row as f32 + 0.5) * tw_y;
|
||||
let tc = to_target([mcx, mcy, mid_z]);
|
||||
// The tile's world rect: the grid cell PLUS the client's outer-edge BLEED. The
|
||||
// cells themselves stride exactly `tw`, sharing their interior edges — but a
|
||||
// cell on the grid's boundary is grown by 1.0 yd on that side alone
|
||||
// (`0x6a549e`/`0x6a54ae`/`0x6a54be`/`0x6a54d1`, each an `fsub`/`fadd` of
|
||||
// `0xca8098 = 0.5 + 0.5`; wow-re `wmo-interior-no-adt-underlay.md` §8, fitted
|
||||
// to the reference's own captured quads with zero error on every bound). A
|
||||
// 1×1 grid is therefore `tw + 2` across, an end cell `tw + 1`, an interior
|
||||
// cell exactly `tw`.
|
||||
//
|
||||
// THIS is what makes the joints work. The bleed grows every group's art 1 yd
|
||||
// past its bbox on each outer side, so two groups whose boxes touch OVERLAP by
|
||||
// 2 yd. Without it their art merely abuts — and abutting art does not survive
|
||||
// the composite's alpha test: `GEQUAL 224/255` on a LINEAR-filtered silhouette
|
||||
// reaches only 0.122 of a texel past the last opaque texel centre (nearest
|
||||
// would reach the texel edge, 0.5), so an abutting pair loses ~0.38 texel and
|
||||
// the black clear reads through the strip for the length of the wall. That is
|
||||
// B141's dashed hairline; a 2 yd overlap absorbs the same erosion with four
|
||||
// texels to spare. In the reference's captured frame 16 of 220 inter-group
|
||||
// tile contacts exist ONLY because of the bleed.
|
||||
let x0 = gn.bbox_min[0] + col as f32 * tw_x
|
||||
- if col == 0 { EDGE_BLEED_YD } else { 0.0 };
|
||||
let x1 = gn.bbox_min[0]
|
||||
+ (col + 1) as f32 * tw_x
|
||||
+ if col + 1 == nx { EDGE_BLEED_YD } else { 0.0 };
|
||||
let y0 = gn.bbox_min[1] + row as f32 * tw_y
|
||||
- if row == 0 { EDGE_BLEED_YD } else { 0.0 };
|
||||
let y1 = gn.bbox_min[1]
|
||||
+ (row + 1) as f32 * tw_y
|
||||
+ if row + 1 == ny { EDGE_BLEED_YD } else { 0.0 };
|
||||
let tc = to_target([0.5 * (x0 + x1), 0.5 * (y0 + y1), mid_z]);
|
||||
// Window cull: skip tiles whose centre lands well outside the TARGET (which
|
||||
// holds 1.5× what the blit shows, so this is wider than the visible disc —
|
||||
// deliberately: the client composites the same margin).
|
||||
@@ -521,20 +581,17 @@ fn emit_minimap(
|
||||
composite.tiles.push(composite::CompositeTile {
|
||||
texture: handle.clone(),
|
||||
center: tc,
|
||||
// The client's HALF-TEXEL UV INSET (`0x4ebfca`–`0x4ec03b`: `du = 0.5/W`,
|
||||
// `dv = 0.5/H`), expressed as the scale it is: sampling `[du, 1-du]` across
|
||||
// a quad of size `S` is the same as sampling `[0, 1]` across a quad of
|
||||
// `S·W/(W−1)` about the same centre, so the shared unit mesh keeps its UVs
|
||||
// and the inset rides the Transform like everything else here. The quad
|
||||
// edges then land on the outermost texel CENTRES, which stretches each
|
||||
// tile's art outward by half a texel per side — and that is what closes
|
||||
// the ~20% of group joints that butt rather than overlap. Without it a
|
||||
// butt joint leaves a texel-wide strip that no tile covers, and the clear
|
||||
// reads through it for the whole length of the wall (B141's residue after
|
||||
// the alpha test alone).
|
||||
// The client's HALF-TEXEL UV INSET, expressed as the scale it is: it
|
||||
// samples `[0.5/W, 1−0.5/W]` across the rect above (verified on the
|
||||
// captured quads — every one carries exactly that UV rect), and sampling
|
||||
// that range across a quad of size `Q` is the same as sampling `[0, 1]`
|
||||
// across `Q·W/(W−1)` about the same centre. So the shared unit mesh keeps
|
||||
// its UVs and the inset rides the Transform like everything else here, and
|
||||
// the texel centres land where the client's do. `W` comes from the TILE
|
||||
// (`tw / 0.5`), never from the bled rect.
|
||||
size: Vec2::new(
|
||||
tw_x * units_per_yd * texel_stretch(tw_x),
|
||||
tw_y * units_per_yd * texel_stretch(tw_y),
|
||||
(x1 - x0) * units_per_yd * texel_stretch(tw_x),
|
||||
(y1 - y0) * units_per_yd * texel_stretch(tw_y),
|
||||
),
|
||||
rotation,
|
||||
order,
|
||||
@@ -543,6 +600,19 @@ fn emit_minimap(
|
||||
}
|
||||
}
|
||||
|
||||
// `WOW_MM_STATS=1` reports what the interior branch actually put in the target this frame —
|
||||
// how many groups the flood-fill kept out of how many, and how many tiles that came to. The
|
||||
// reference's own Stormwind capture emitted 57 tiles at indoor zoom 3, which is the number
|
||||
// this is here to be compared against (wow-re `wmo-interior-no-adt-underlay.md`).
|
||||
if std::env::var("WOW_MM_STATS").is_ok() {
|
||||
eprintln!(
|
||||
"MM-STATS: radius {radius} yd, groups {}/{} selected, {} tiles composited",
|
||||
drawable.iter().filter(|d| **d).count(),
|
||||
drawable.len(),
|
||||
composite.tiles.len(),
|
||||
);
|
||||
}
|
||||
|
||||
// THE BLIT: the target's middle two-thirds, which is what nets `1.0 · radius` on screen
|
||||
// (the client's `0x4ec440` under EGxBlend 2). One quad, masked to the minimap circle — the
|
||||
// round cut belongs HERE and not to the tiles, exactly as the reference's does. The target
|
||||
@@ -871,6 +941,10 @@ impl Plugin for MinimapPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
// The headless probe's synthetic widget, ahead of the emit that reads it.
|
||||
probe_minimap_widget
|
||||
.in_set(UiQuadAppend)
|
||||
.before(emit_minimap),
|
||||
emit_minimap.in_set(UiQuadAppend),
|
||||
// After the emit that fills it: the composite camera draws what THIS frame's
|
||||
// interior branch asked for, so the target the blit quad samples is never a
|
||||
|
||||
@@ -110,6 +110,15 @@ fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
|
||||
return select(higher, lower, c <= vec3<f32>(0.0031308));
|
||||
}
|
||||
|
||||
// sRGB → linear — the inverse of `linear_to_srgb`, for a texture uploaded UNDECODED so the hardware
|
||||
// filters its authored bytes (the minimap tiles' `GL_SKIP_DECODE_EXT`). The conversion happens here,
|
||||
// after the filter, which is exactly the order the reference's fixed-function pipe uses.
|
||||
fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
|
||||
let higher = pow((max(c, vec3<f32>(0.0)) + 0.055) / 1.055, vec3<f32>(2.4));
|
||||
let lower = c / 12.92;
|
||||
return select(higher, lower, c <= vec3<f32>(0.04045));
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
let t = textureSample(quad_texture, quad_sampler, in.uv);
|
||||
@@ -157,7 +166,9 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
if t.a * c.a < alpha_ref {
|
||||
discard;
|
||||
}
|
||||
return vec4<f32>(t.rgb * c.rgb, 1.0);
|
||||
// The tile arrived as gamma bytes (SKIP_DECODE) and the composite target is un-encoded, so
|
||||
// the conversion the sampler did not do happens HERE — after the filter, which is the point.
|
||||
return vec4<f32>(srgb_to_linear(t.rgb) * c.rgb, 1.0);
|
||||
}
|
||||
let a = t.a * k;
|
||||
// Premultiply in GAMMA (decision 0160's lesson, here for the UI): the hardware `SrcAlpha` factor
|
||||
|
||||
@@ -43,6 +43,9 @@ pub enum BlpVariant {
|
||||
/// and the field becomes flickering speckle.
|
||||
/// (wow-re `system/lighting/scratch/rf-snow-flake-render.md` §2.1/§6.)
|
||||
PointSprite,
|
||||
/// A **minimap tile** (ADT `map<X>_<Y>` or a WMO group's interior tile) — clamp, mip 0, linear,
|
||||
/// and filtered in **gamma space**, the reference's `GL_SKIP_DECODE_EXT`. See [`map_tile_image`].
|
||||
MapTile,
|
||||
/// OS cursor image — `Rgba8UnormSrgb`, single mip.
|
||||
Cursor,
|
||||
}
|
||||
@@ -82,6 +85,10 @@ impl AssetLoader for BlpImageLoader {
|
||||
let (w, h, rgba) = blp_to_rgba(&bytes).map_err(to_io)?;
|
||||
sprite_image(w, h, rgba)
|
||||
}
|
||||
BlpVariant::MapTile => {
|
||||
let (w, h, rgba) = blp_to_rgba(&bytes).map_err(to_io)?;
|
||||
map_tile_image(w, h, rgba)
|
||||
}
|
||||
BlpVariant::Effect => {
|
||||
let (w, h, rgba) = blp_to_rgba(&bytes).map_err(to_io)?;
|
||||
effect_image(w, h, rgba)
|
||||
@@ -181,6 +188,27 @@ fn sprite_image(width: u32, height: u32, rgba: Vec<u8>) -> Image {
|
||||
image
|
||||
}
|
||||
|
||||
/// A **minimap tile** — clamp, mip 0, **LINEAR**, and filtered in **GAMMA** space.
|
||||
///
|
||||
/// The reference binds one sampler object for every minimap tile it draws, interior and outdoor
|
||||
/// alike, and the trace reads it whole: `CLAMP_TO_EDGE` both axes, `MAG_FILTER = MIN_FILTER =
|
||||
/// GL_LINEAR` with no mip term, LOD bias 0, anisotropy 1, and — the part that is not the default —
|
||||
/// **`GL_TEXTURE_SRGB_DECODE_EXT = GL_SKIP_DECODE_EXT`**, plus `GL_TEXTURE_MAX_LEVEL = 0` per tile.
|
||||
/// (wow-re `system/minimap/scratch/wmo-interior-no-adt-underlay.md` §7, device-observed and matching
|
||||
/// its own binary derivation; a positive control in the same trace emits `GL_NEAREST` for a
|
||||
/// different sampler, so the layer would have shown point sampling had the client asked for it.)
|
||||
///
|
||||
/// SKIP_DECODE is the whole reason this is not [`sprite_image`]: it means the hardware **filters the
|
||||
/// authored bytes**, not their linearisation. An `Rgba8UnormSrgb` upload decodes each texel to
|
||||
/// linear before the filter weights it, so every blend across an edge lands on a different colour
|
||||
/// than the reference's — darker, because averaging in linear space and re-encoding is darker than
|
||||
/// averaging the gamma bytes. On a 1-bit-alpha bake whose edges sit against black that is a visible
|
||||
/// dark fringe at every alpha boundary. So the tile arrives as gamma bytes ([`color_format`], no
|
||||
/// decode) and the shader converts AFTER the filter.
|
||||
fn map_tile_image(width: u32, height: u32, rgba: Vec<u8>) -> Image {
|
||||
effect_image(width, height, rgba)
|
||||
}
|
||||
|
||||
/// Gamma-lane effect sprite: `Rgba8Unorm` (the shader does WoW's byte-space combine), clamp,
|
||||
/// **mip 0 only** — see [`BlpVariant::Effect`].
|
||||
fn effect_image(width: u32, height: u32, rgba: Vec<u8>) -> Image {
|
||||
|
||||
@@ -701,6 +701,7 @@ impl AssetLoader for WmoModelLoader {
|
||||
positions: cam_pos,
|
||||
indices: cam_idx,
|
||||
});
|
||||
resolve_shared_liquid_cells(&mut group_liquids);
|
||||
let portals = root.portals();
|
||||
let (group_collision_bounds, collision_bounds) =
|
||||
collision_tri_bounds(&group_collision_tris);
|
||||
@@ -746,6 +747,65 @@ impl AssetLoader for WmoModelLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the MLIQ **shared-tile gate** across a model's groups: a cell flagged `0x80`
|
||||
/// ([`LiquidMesh::shared`]) is claimed by TWO groups and authored in both, and exactly one of them
|
||||
/// may draw it. Both drawing means the translucent sheet composites twice over the overlap band —
|
||||
/// the visible line along the Stormwind canals (B141's water half).
|
||||
///
|
||||
/// The reference picks the winner by 2-colouring the portal graph on the depth parity of the flood
|
||||
/// that reached each group, recomputed every frame (`0x6b41c0`/`0x6b4074`/`0x6b61a0`, wow-re
|
||||
/// `terrain/scratch/wmo-liquid-shared-tile-gate.md`). We pick **the lowest group index**, which is
|
||||
/// pixel-identical and needs no flood: the RE measured all 415 corners of Stormwind's 190 shared
|
||||
/// cells and the two claimants agree exactly — the same per-vertex alpha byte (415/415) and heights
|
||||
/// within 5e-6 yd. Which one draws cannot be seen; that two draw can.
|
||||
///
|
||||
/// Cells are matched on their XY centre quantised to a hundredth of a yard — the two authors share
|
||||
/// the same MLIQ lattice, so the coordinates agree to float exactness and the quantisation is only
|
||||
/// there to make them hashable.
|
||||
fn resolve_shared_liquid_cells(group_liquids: &mut [Option<LiquidMesh>]) {
|
||||
let key = |m: &LiquidMesh, c: usize| -> Option<(i32, i32)> {
|
||||
let (cols, rows) = (m.grid[0] as usize, m.grid[1] as usize);
|
||||
let (xt, yt) = (cols.checked_sub(1)?, rows.checked_sub(1)?);
|
||||
let (tx, ty) = (c % xt, c / xt);
|
||||
if ty >= yt {
|
||||
return None;
|
||||
}
|
||||
let a = m.positions.get(ty * cols + tx)?;
|
||||
let b = m.positions.get((ty + 1) * cols + tx + 1)?;
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
Some((
|
||||
(0.5 * (a[0] + b[0]) * 100.0).round() as i32,
|
||||
(0.5 * (a[1] + b[1]) * 100.0).round() as i32,
|
||||
))
|
||||
};
|
||||
// First claimant wins, walking groups in index order.
|
||||
let mut owner: bevy::platform::collections::HashMap<(i32, i32), usize> =
|
||||
bevy::platform::collections::HashMap::default();
|
||||
for (gi, slot) in group_liquids.iter().enumerate() {
|
||||
let Some(m) = slot else { continue };
|
||||
for c in 0..m.wet.len() {
|
||||
if !m.wet[c] || !m.shared[c] {
|
||||
continue;
|
||||
}
|
||||
if let Some(k) = key(m, c) {
|
||||
owner.entry(k).or_insert(gi);
|
||||
}
|
||||
}
|
||||
}
|
||||
if owner.is_empty() {
|
||||
return;
|
||||
}
|
||||
for (gi, slot) in group_liquids.iter_mut().enumerate() {
|
||||
let Some(m) = slot else { continue };
|
||||
let keys: Vec<Option<(i32, i32)>> = (0..m.wet.len()).map(|c| key(m, c)).collect();
|
||||
let shared = m.shared.clone();
|
||||
m.retain_cells(|c| !shared[c] || keys[c].is_none_or(|k| owner.get(&k) == Some(&gi)));
|
||||
if m.indices.is_empty() {
|
||||
*slot = None; // every cell this group held belonged to a neighbour
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod doodad_base_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
//! TEMP (B141): rasterise the WMO-interior minimap **offline**, from the shipped tiles and the
|
||||
//! client's own placement + alpha test, so "where do the black lines come from" is a measurement on
|
||||
//! the data rather than a reading of a screenshot.
|
||||
//!
|
||||
//! Writes three PNGs: `colour` (what the composite would show), `cover` (white where SOME tile
|
||||
//! passes the 224/255 test), and `any` (white where some tile has alpha > 0 at all). A line that is
|
||||
//! black in `cover` but white in `any` is the alpha test cutting art that exists; black in both is
|
||||
//! a genuine hole in the bake — or a placement that never covers that spot.
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Cursor;
|
||||
|
||||
const MAP_CENTER: f32 = 17066.666;
|
||||
const YD_PER_TEXEL: f32 = 0.5;
|
||||
const TILE_SPAN_YD: f32 = 128.0;
|
||||
const ALPHA_REF: u8 = 224;
|
||||
|
||||
fn next_pow2(n: u32) -> u32 {
|
||||
if n <= 1 {
|
||||
1
|
||||
} else {
|
||||
1u32 << (32 - (n - 1).leading_zeros())
|
||||
}
|
||||
}
|
||||
/// `benilla_assets::minimap_grid::group_axis_grid`, inlined so this example needs no bevy dep.
|
||||
fn group_axis_grid(extent: f32) -> (u32, f32) {
|
||||
let texels = (extent / YD_PER_TEXEL).max(1.0);
|
||||
let px = next_pow2(texels.ceil() as u32).clamp(32, 256);
|
||||
(
|
||||
((extent / TILE_SPAN_YD).ceil().max(1.0)) as u32,
|
||||
px as f32 * YD_PER_TEXEL,
|
||||
)
|
||||
}
|
||||
|
||||
fn model_to_world(rot_deg: [f32; 3], p: [f32; 3]) -> [f32; 3] {
|
||||
let (rx, ry, rz) = (
|
||||
rot_deg[0].to_radians(),
|
||||
rot_deg[1].to_radians(),
|
||||
rot_deg[2].to_radians(),
|
||||
);
|
||||
let rot_x = |p: [f32; 3], a: f32| {
|
||||
let (s, c) = a.sin_cos();
|
||||
[p[0], p[1] * c - p[2] * s, p[1] * s + p[2] * c]
|
||||
};
|
||||
let rot_y = |p: [f32; 3], a: f32| {
|
||||
let (s, c) = a.sin_cos();
|
||||
[p[0] * c + p[2] * s, p[1], -p[0] * s + p[2] * c]
|
||||
};
|
||||
let rot_z = |p: [f32; 3], a: f32| {
|
||||
let (s, c) = a.sin_cos();
|
||||
[p[0] * c - p[1] * s, p[0] * s + p[1] * c, p[2]]
|
||||
};
|
||||
let p = rot_x(p, rz - std::f32::consts::FRAC_PI_2);
|
||||
let p = rot_z(p, -rx);
|
||||
let p = rot_y(p, ry - std::f32::consts::PI);
|
||||
rot_x(p, std::f32::consts::FRAC_PI_2)
|
||||
}
|
||||
/// Inverse of [`model_to_world`] (the rotation is orthonormal, so: same angles, reversed order).
|
||||
fn world_to_model(rot_deg: [f32; 3], p: [f32; 3]) -> [f32; 3] {
|
||||
let (rx, ry, rz) = (
|
||||
rot_deg[0].to_radians(),
|
||||
rot_deg[1].to_radians(),
|
||||
rot_deg[2].to_radians(),
|
||||
);
|
||||
let rot_x = |p: [f32; 3], a: f32| {
|
||||
let (s, c) = a.sin_cos();
|
||||
[p[0], p[1] * c - p[2] * s, p[1] * s + p[2] * c]
|
||||
};
|
||||
let rot_y = |p: [f32; 3], a: f32| {
|
||||
let (s, c) = a.sin_cos();
|
||||
[p[0] * c + p[2] * s, p[1], -p[0] * s + p[2] * c]
|
||||
};
|
||||
let rot_z = |p: [f32; 3], a: f32| {
|
||||
let (s, c) = a.sin_cos();
|
||||
[p[0] * c - p[1] * s, p[0] * s + p[1] * c, p[2]]
|
||||
};
|
||||
let p = rot_x(p, -std::f32::consts::FRAC_PI_2);
|
||||
let p = rot_y(p, -(ry - std::f32::consts::PI));
|
||||
let p = rot_z(p, rx);
|
||||
rot_x(p, -(rz - std::f32::consts::FRAC_PI_2))
|
||||
}
|
||||
|
||||
struct Tile {
|
||||
gi: usize,
|
||||
/// The drawn rect (grid cell + outer-edge bleed).
|
||||
x0: f32,
|
||||
y0: f32,
|
||||
tw: f32,
|
||||
th: f32,
|
||||
w: u32,
|
||||
h: u32,
|
||||
rgba: Vec<u8>,
|
||||
midz: f32,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let pin = [
|
||||
args.first().map_or(-8796.83, |s| s.parse().unwrap()),
|
||||
args.get(1).map_or(744.25, |s| s.parse().unwrap()),
|
||||
args.get(2).map_or(97.83, |s| s.parse().unwrap()),
|
||||
];
|
||||
let half: f32 = args.get(3).map_or(90.0, |s| s.parse().unwrap());
|
||||
let out = std::env::var("MM_OUT").unwrap_or_else(|_| ".".into());
|
||||
|
||||
let data = benilla_formats::wow_data().expect("no WoW install");
|
||||
let mut chain = benilla_formats::open_chain(&data)?;
|
||||
let trs = benilla_formats::load_minimap_translate(&mut chain)?;
|
||||
|
||||
// Find the placement whose model bbox contains the pin, over every WMO placed in this ADT.
|
||||
let (tc, tr) = benilla_formats::world_to_tile(pin[0], pin[1]);
|
||||
let adt_name = format!("World\\Maps\\Azeroth\\Azeroth_{tc}_{tr}.adt");
|
||||
let bytes = chain.read_file(&adt_name)?;
|
||||
let benilla_adt::ParsedAdt::Root(adt) = benilla_adt::parse_adt(&mut Cursor::new(&*bytes))?;
|
||||
let mut best: Option<(String, [f32; 3], [f32; 3], f32)> = None;
|
||||
for p in &adt.wmo_placements {
|
||||
let Some(model) = adt.wmos.get(p.name_id as usize) else {
|
||||
continue;
|
||||
};
|
||||
let o = [
|
||||
MAP_CENTER - p.position[2],
|
||||
MAP_CENTER - p.position[0],
|
||||
p.position[1],
|
||||
];
|
||||
let d = (o[0] - pin[0]).hypot(o[1] - pin[1]);
|
||||
if best.as_ref().is_none_or(|b| d < b.3) {
|
||||
best = Some((model.replace('/', "\\"), o, p.rotation, d));
|
||||
}
|
||||
}
|
||||
let (model, origin, rot, _) = best.expect("no WMO placement in this ADT");
|
||||
// MM_MODEL="x,y[,z]" pins in the WMO's MODEL space instead — how a captured reference frame
|
||||
// states the player's position, so a capture can be reproduced exactly.
|
||||
let pm = match std::env::var("MM_MODEL") {
|
||||
Ok(v) => {
|
||||
let n: Vec<f32> = v.split(',').map(|t| t.trim().parse().unwrap()).collect();
|
||||
[n[0], n[1], *n.get(2).unwrap_or(&0.0)]
|
||||
}
|
||||
Err(_) => world_to_model(
|
||||
rot,
|
||||
[pin[0] - origin[0], pin[1] - origin[1], pin[2] - origin[2]],
|
||||
),
|
||||
};
|
||||
let back = model_to_world(rot, pm);
|
||||
eprintln!("model {model}\n origin {origin:?} rot {rot:?}\n pin in model space: {pm:?}\n round-trip: {back:?} vs {:?}",
|
||||
[pin[0] - origin[0], pin[1] - origin[1], pin[2] - origin[2]]);
|
||||
|
||||
let stem = model
|
||||
.trim_end_matches(".wmo")
|
||||
.trim_end_matches(".WMO")
|
||||
.to_string();
|
||||
let key_stem = stem
|
||||
.to_ascii_lowercase()
|
||||
.split_once("world\\")
|
||||
.map(|(_, r)| r.to_string())
|
||||
.unwrap();
|
||||
let root = benilla_formats::parse_wmo_root(&chain.read_file(&format!("{stem}.wmo"))?)?;
|
||||
let infos = root.group_infos().to_vec();
|
||||
let (mut glo, mut ghi) = ([f32::INFINITY; 3], [f32::NEG_INFINITY; 3]);
|
||||
for i in &infos {
|
||||
for k in 0..3 {
|
||||
glo[k] = glo[k].min(i.bbox_min[k]);
|
||||
ghi[k] = ghi[k].max(i.bbox_max[k]);
|
||||
}
|
||||
}
|
||||
eprintln!("{} groups, model bbox {glo:?} .. {ghi:?}", infos.len());
|
||||
|
||||
// MM_AUDIT: our computed per-group tile grid against the one actually authored in the trs.
|
||||
// A short grid leaves world the composite can never cover, whatever the group selection does.
|
||||
if std::env::var("MM_AUDIT").is_ok() {
|
||||
let (mut bad, mut tiles_ours, mut tiles_theirs) = (0usize, 0usize, 0usize);
|
||||
for (gi, info) in infos.iter().enumerate() {
|
||||
let (mut mc, mut mr, mut present) = (-1i32, -1i32, 0usize);
|
||||
for c in 0..16u32 {
|
||||
for r in 0..16u32 {
|
||||
if trs
|
||||
.get(&format!("{key_stem}_{gi:03}_{c:02}_{r:02}.blp"))
|
||||
.is_some()
|
||||
{
|
||||
mc = mc.max(c as i32);
|
||||
mr = mr.max(r as i32);
|
||||
present += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if present == 0 {
|
||||
continue;
|
||||
}
|
||||
let (nx, twx) = group_axis_grid(info.bbox_max[0] - info.bbox_min[0]);
|
||||
let (ny, twy) = group_axis_grid(info.bbox_max[1] - info.bbox_min[1]);
|
||||
tiles_ours += (nx * ny) as usize;
|
||||
tiles_theirs += present;
|
||||
if nx as i32 != mc + 1 || ny as i32 != mr + 1 {
|
||||
bad += 1;
|
||||
if bad <= 20 {
|
||||
println!(
|
||||
" g{gi:3}: ours {nx}x{ny} (tile {twx}x{twy} yd), authored {}x{} ({present} tiles) extent {:.1} x {:.1}",
|
||||
mc + 1, mr + 1,
|
||||
info.bbox_max[0] - info.bbox_min[0], info.bbox_max[1] - info.bbox_min[1],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("AUDIT: {bad} groups with a grid mismatch; we ask for {tiles_ours} tiles, {tiles_theirs} are authored");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// MM_BLP: what the tile art actually declares and holds — the alpha histogram of every tile
|
||||
// near the pin, so "transparent" is a fact about the file rather than about our decoder.
|
||||
// MM_FIT: the authored tile's PIXEL size against the group's world extent — i.e. what the bake's
|
||||
// real yards-per-texel is, per group, instead of the 0.5 we assume.
|
||||
if std::env::var("MM_FIT").is_ok() {
|
||||
println!(" gi authored px grid extent yd yd/texel (x, y)");
|
||||
let mut ratios: Vec<f32> = Vec::new();
|
||||
for (gi, info) in infos.iter().enumerate() {
|
||||
let (mut mc, mut mr) = (-1i32, -1i32);
|
||||
let mut dim = (0u32, 0u32);
|
||||
for c in 0..16u32 {
|
||||
for r in 0..16u32 {
|
||||
let key = format!("{key_stem}_{gi:03}_{c:02}_{r:02}.blp");
|
||||
let Some(hashed) = trs.get(&key) else {
|
||||
continue;
|
||||
};
|
||||
mc = mc.max(c as i32);
|
||||
mr = mr.max(r as i32);
|
||||
if c == 0 && r == 0 {
|
||||
if let Ok(b) = chain.read_file(&format!("textures\\Minimap\\{hashed}")) {
|
||||
if let Ok(d) = benilla_blp::decode(&b) {
|
||||
dim = (d.width, d.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if mc < 0 || dim.0 == 0 {
|
||||
continue;
|
||||
}
|
||||
let (ex, ey) = (
|
||||
info.bbox_max[0] - info.bbox_min[0],
|
||||
info.bbox_max[1] - info.bbox_min[1],
|
||||
);
|
||||
// Tile art is transposed vs the model axes: image WIDTH runs along model Y, HEIGHT along X.
|
||||
let (nx, ny) = ((mc + 1) as f32, (mr + 1) as f32);
|
||||
let ypt_x = ex / (dim.1 as f32 * ny);
|
||||
let ypt_y = ey / (dim.0 as f32 * nx);
|
||||
ratios.push(ypt_x);
|
||||
ratios.push(ypt_y);
|
||||
if gi < 24 {
|
||||
println!(
|
||||
" g{gi:3} {:3}x{:<3} {}x{} {ex:7.1} x {ey:7.1} {ypt_x:.4} {ypt_y:.4}",
|
||||
dim.0,
|
||||
dim.1,
|
||||
mc + 1,
|
||||
mr + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
ratios.sort_by(f32::total_cmp);
|
||||
let mean = ratios.iter().sum::<f32>() / ratios.len() as f32;
|
||||
println!(
|
||||
"yd/texel over {} axes: min {:.4} median {:.4} max {:.4} mean {mean:.4}",
|
||||
ratios.len(),
|
||||
ratios[0],
|
||||
ratios[ratios.len() / 2],
|
||||
ratios[ratios.len() - 1]
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// MM_ANCHOR: where inside its power-of-two texture does a single-tile group's OPAQUE art sit,
|
||||
// and how big is it — against the group's own extent at 0.5 yd/texel. This is what says whether
|
||||
// the bake pads (art at one corner, transparent remainder) or stretches, and which corner.
|
||||
if std::env::var("MM_ANCHOR").is_ok() {
|
||||
println!(" gi tex opaque box (u0,u1)x(v0,v1) extent/0.5 texels fill");
|
||||
for (gi, info) in infos.iter().enumerate().take(40) {
|
||||
let key = format!("{key_stem}_{gi:03}_00_00.blp");
|
||||
let Some(hashed) = trs.get(&key) else {
|
||||
continue;
|
||||
};
|
||||
if trs.get(&format!("{key_stem}_{gi:03}_01_00.blp")).is_some()
|
||||
|| trs.get(&format!("{key_stem}_{gi:03}_00_01.blp")).is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(b) = chain.read_file(&format!("textures\\Minimap\\{hashed}")) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(d) = benilla_blp::decode(&b) else {
|
||||
continue;
|
||||
};
|
||||
let m = &d.mips[0];
|
||||
let (mut u0, mut u1, mut v0, mut v1) = (u32::MAX, 0u32, u32::MAX, 0u32);
|
||||
for v in 0..m.height {
|
||||
for u in 0..m.width {
|
||||
if m.rgba[((v * m.width + u) * 4 + 3) as usize] != 0 {
|
||||
u0 = u0.min(u);
|
||||
u1 = u1.max(u);
|
||||
v0 = v0.min(v);
|
||||
v1 = v1.max(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
if u0 == u32::MAX {
|
||||
continue;
|
||||
}
|
||||
let (ex, ey) = (
|
||||
info.bbox_max[0] - info.bbox_min[0],
|
||||
info.bbox_max[1] - info.bbox_min[1],
|
||||
);
|
||||
println!(" g{gi:3} {:3}x{:<3} u[{u0:3},{u1:3}] v[{v0:3},{v1:3}] X {:.1} tx, Y {:.1} tx {:.0}x{:.0}",
|
||||
m.width, m.height, ex / YD_PER_TEXEL, ey / YD_PER_TEXEL, u1 - u0 + 1, v1 - v0 + 1);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// MM_MULTI: the same anchor read on MULTI-tile groups, which is what says which name index runs
|
||||
// along which model axis and where the power-of-two padding lands across a row of tiles.
|
||||
if std::env::var("MM_MULTI").is_ok() {
|
||||
let mut shown = 0;
|
||||
for (gi, info) in infos.iter().enumerate() {
|
||||
let mut cells = Vec::new();
|
||||
for c in 0..8u32 {
|
||||
for r in 0..8u32 {
|
||||
if let Some(h) = trs.get(&format!("{key_stem}_{gi:03}_{c:02}_{r:02}.blp")) {
|
||||
cells.push((c, r, h.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if cells.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let (ex, ey) = (
|
||||
info.bbox_max[0] - info.bbox_min[0],
|
||||
info.bbox_max[1] - info.bbox_min[1],
|
||||
);
|
||||
println!(
|
||||
"g{gi} extent X {ex:.1} ({:.1} tx) Y {ey:.1} ({:.1} tx), {} tiles",
|
||||
ex / 0.5,
|
||||
ey / 0.5,
|
||||
cells.len()
|
||||
);
|
||||
for (c, r, h) in &cells {
|
||||
let Ok(b) = chain.read_file(&format!("textures\\Minimap\\{h}")) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(d) = benilla_blp::decode(&b) else {
|
||||
continue;
|
||||
};
|
||||
let m = &d.mips[0];
|
||||
let (mut u0, mut u1, mut v0, mut v1) = (u32::MAX, 0u32, u32::MAX, 0u32);
|
||||
for v in 0..m.height {
|
||||
for u in 0..m.width {
|
||||
if m.rgba[((v * m.width + u) * 4 + 3) as usize] != 0 {
|
||||
u0 = u0.min(u);
|
||||
u1 = u1.max(u);
|
||||
v0 = v0.min(v);
|
||||
v1 = v1.max(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
" {c}_{r}: {:3}x{:<3} opaque u[{u0:3},{u1:3}] v[{v0:3},{v1:3}]",
|
||||
m.width, m.height
|
||||
);
|
||||
}
|
||||
shown += 1;
|
||||
if shown >= 6 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if std::env::var("MM_BLP").is_ok() {
|
||||
for gi in 0..infos.len() {
|
||||
for c in 0..4u32 {
|
||||
for r in 0..4u32 {
|
||||
let key = format!("{key_stem}_{gi:03}_{c:02}_{r:02}.blp");
|
||||
let Some(hashed) = trs.get(&key) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(b) = chain.read_file(&format!("textures\\Minimap\\{hashed}")) else {
|
||||
continue;
|
||||
};
|
||||
let hdr = (
|
||||
u32::from_le_bytes(b[8..12].try_into().unwrap()), // compression
|
||||
b[12],
|
||||
b[13],
|
||||
b[14],
|
||||
b[15], // alphaDepth, alphaType, hasMips, ?
|
||||
);
|
||||
let Ok(d) = benilla_blp::decode(&b) else {
|
||||
continue;
|
||||
};
|
||||
let m = &d.mips[0];
|
||||
let zero = m.rgba.chunks(4).filter(|p| p[3] == 0).count();
|
||||
let full = m.rgba.chunks(4).filter(|p| p[3] == 255).count();
|
||||
let n = m.rgba.len() / 4;
|
||||
println!("g{gi:3} {c}_{r} {}x{} hdr(comp={} aDepth={} aType={} mips={}) alpha0 {:.1}% alpha255 {:.1}% other {}",
|
||||
m.width, m.height, hdr.0, hdr.1, hdr.2, hdr.3,
|
||||
100.0 * zero as f32 / n as f32, 100.0 * full as f32 / n as f32, n - zero - full);
|
||||
if gi > 60 {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (lo, hi) = ([pm[0] - half, pm[1] - half], [pm[0] + half, pm[1] + half]);
|
||||
let mut tiles: Vec<Tile> = Vec::new();
|
||||
let mut cache: BTreeMap<String, Option<(u32, u32, Vec<u8>)>> = BTreeMap::new();
|
||||
for (gi, info) in infos.iter().enumerate() {
|
||||
let (nx, twx) = group_axis_grid(info.bbox_max[0] - info.bbox_min[0]);
|
||||
let (ny, twy) = group_axis_grid(info.bbox_max[1] - info.bbox_min[1]);
|
||||
for col in 0..nx {
|
||||
for row in 0..ny {
|
||||
// MEASURED tile layout (MM_ANCHOR / MM_MULTI, read off the shipped art): image u
|
||||
// runs along model +X with u_global = 0 at the group's bbox X-MIN, column index
|
||||
// advancing with +X; image v runs along model Y INVERTED, the grid anchored at its
|
||||
// BOTTOM so the last row's last texel sits at the group's Y anchor.
|
||||
// The verified rect: cells stride `tw` and share their interior edges, and a cell
|
||||
// on the grid's boundary is grown 1.0 yd on that side alone (wow-re
|
||||
// `wmo-interior-no-adt-underlay.md` §8). `MM_NOBLEED` drops the bleed, which is
|
||||
// what B141 was: without it two groups' art abuts instead of overlapping by 2 yd.
|
||||
let bleed = if std::env::var("MM_NOBLEED").is_ok() {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let x0 = info.bbox_min[0] + col as f32 * twx - if col == 0 { bleed } else { 0.0 };
|
||||
let x1 = info.bbox_min[0]
|
||||
+ (col + 1) as f32 * twx
|
||||
+ if col + 1 == nx { bleed } else { 0.0 };
|
||||
let y0 = info.bbox_min[1] + row as f32 * twy - if row == 0 { bleed } else { 0.0 };
|
||||
let y1 = info.bbox_min[1]
|
||||
+ (row + 1) as f32 * twy
|
||||
+ if row + 1 == ny { bleed } else { 0.0 };
|
||||
if x0 > hi[0] || x1 < lo[0] || y0 > hi[1] || y1 < lo[1] {
|
||||
continue;
|
||||
}
|
||||
let key = format!("{key_stem}_{gi:03}_{col:02}_{row:02}.blp");
|
||||
let dec = cache.entry(key.clone()).or_insert_with(|| {
|
||||
let hashed = trs.get(&key)?;
|
||||
let b = chain
|
||||
.read_file(&format!("textures\\Minimap\\{hashed}"))
|
||||
.ok()?;
|
||||
let d = benilla_blp::decode(&b).ok()?;
|
||||
let m = d.mips.first()?;
|
||||
Some((m.width, m.height, m.rgba.clone()))
|
||||
});
|
||||
let Some((w, h, rgba)) = dec.clone() else {
|
||||
continue;
|
||||
};
|
||||
tiles.push(Tile {
|
||||
gi,
|
||||
x0,
|
||||
y0,
|
||||
tw: x1 - x0,
|
||||
th: y1 - y0,
|
||||
w,
|
||||
h,
|
||||
rgba,
|
||||
midz: 0.5 * (info.bbox_min[2] + info.bbox_max[2]),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let hits = cache.values().filter(|v| v.is_some()).count();
|
||||
eprintln!(
|
||||
"{} tiles overlap the window ({} keys probed, {hits} resolved)",
|
||||
tiles.len(),
|
||||
cache.len()
|
||||
);
|
||||
for k in cache.iter().filter(|(_, v)| v.is_none()).take(4) {
|
||||
eprintln!(" MISS {}", k.0);
|
||||
}
|
||||
// Same order the app draws in: by group bbox Z-mid relative to the player, ascending.
|
||||
tiles.sort_by(|a, b| {
|
||||
(a.midz - pm[2])
|
||||
.abs()
|
||||
.total_cmp(&(b.midz - pm[2]).abs())
|
||||
.reverse()
|
||||
});
|
||||
|
||||
let px = (half * 2.0 / YD_PER_TEXEL) as u32;
|
||||
let mut colour = vec![0u8; (px * px * 3) as usize];
|
||||
let mut cover = vec![0u8; (px * px) as usize];
|
||||
let mut anyv = vec![0u8; (px * px) as usize];
|
||||
let mut owner = vec![0u8; (px * px * 3) as usize];
|
||||
let bilinear = std::env::var("MM_BILINEAR").is_ok();
|
||||
// OPTIONAL UNDERLAY: the outdoor ADT minimap tiles (`Azeroth\\map<x>_<y>.blp`), sampled through
|
||||
// the placement so they land under the WMO tiles in the same frame. `MM_TERRAIN=1`.
|
||||
if std::env::var("MM_TERRAIN").is_ok() {
|
||||
let mut adt_cache: BTreeMap<(u32, u32), Option<Vec<u8>>> = BTreeMap::new();
|
||||
for iy in 0..px {
|
||||
for ix in 0..px {
|
||||
let my = pm[1] + half - (ix as f32 + 0.5) * YD_PER_TEXEL;
|
||||
let mx = pm[0] + half - (iy as f32 + 0.5) * YD_PER_TEXEL;
|
||||
let w = model_to_world(rot, [mx, my, pm[2]]);
|
||||
let (wx, wy) = (origin[0] + w[0], origin[1] + w[1]);
|
||||
let (tx, ty) = benilla_formats::world_to_tile(wx, wy);
|
||||
let e = adt_cache.entry((tx, ty)).or_insert_with(|| {
|
||||
let key = format!("Azeroth\\map{tx}_{ty}.blp");
|
||||
let hashed = trs.get(&key)?;
|
||||
let b = chain
|
||||
.read_file(&format!("textures\\Minimap\\{hashed}"))
|
||||
.ok()?;
|
||||
let d = benilla_blp::decode(&b).ok()?;
|
||||
Some(d.mips.first()?.rgba.clone())
|
||||
});
|
||||
let Some(rgba) = e else { continue };
|
||||
let (ox, oy) = benilla_formats::tile_to_world(tx, ty);
|
||||
let u = (((oy - wy) / 533.3333) * 256.0) as u32;
|
||||
let v = (((ox - wx) / 533.3333) * 256.0) as u32;
|
||||
let s = ((v.min(255) * 256 + u.min(255)) * 4) as usize;
|
||||
let o = (iy * px + ix) as usize;
|
||||
cover[o] = 255;
|
||||
colour[o * 3..o * 3 + 3].copy_from_slice(&rgba[s..s + 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for t in &tiles {
|
||||
let gcol = [
|
||||
(t.gi * 97 % 251) as u8,
|
||||
(t.gi * 57 % 241) as u8,
|
||||
(t.gi * 151 % 233) as u8,
|
||||
];
|
||||
for iy in 0..px {
|
||||
for ix in 0..px {
|
||||
// Screen: +x is model −y, +y (down) is model −x (the app's `to_target`).
|
||||
let my = pm[1] + half - (ix as f32 + 0.5) * YD_PER_TEXEL;
|
||||
let mx = pm[0] + half - (iy as f32 + 0.5) * YD_PER_TEXEL;
|
||||
if mx < t.x0 || mx >= t.x0 + t.tw || my < t.y0 || my >= t.y0 + t.th {
|
||||
continue;
|
||||
}
|
||||
// MM_BILINEAR reproduces the reference's LINEAR sampler (CLAMP_TO_EDGE), so the
|
||||
// alpha the 224/255 test sees is the interpolated one — which is what decides how
|
||||
// far a tile's opaque art actually reaches past its last opaque texel CENTRE
|
||||
// (0.122 of a texel at ref 224, against nearest's half-texel to the texel EDGE).
|
||||
// The client samples `[0.5/W, 1−0.5/W]` across the drawn rect, so the W texel
|
||||
// CENTRES span it exactly: step = rect / (W−1), texel 0's centre on the rect edge.
|
||||
let fu = (mx - t.x0) / (t.tw / (t.w as f32 - 1.0));
|
||||
let fv = (t.y0 + t.th - my) / (t.th / (t.h as f32 - 1.0));
|
||||
let (u, v) = (
|
||||
(fu + 0.5).max(0.0).min(t.w as f32 - 1.0) as u32,
|
||||
(fv + 0.5).max(0.0).min(t.h as f32 - 1.0) as u32,
|
||||
);
|
||||
let s = ((v * t.w + u) * 4) as usize;
|
||||
let a = if bilinear {
|
||||
let (i0, j0) = (fu.floor(), fv.floor());
|
||||
let (wx, wy) = (fu - i0, fv - j0);
|
||||
let at = |i: f32, j: f32| -> f32 {
|
||||
let i = (i.max(0.0) as u32).min(t.w - 1);
|
||||
let j = (j.max(0.0) as u32).min(t.h - 1);
|
||||
f32::from(t.rgba[((j * t.w + i) * 4 + 3) as usize])
|
||||
};
|
||||
let top = at(i0, j0) * (1.0 - wx) + at(i0 + 1.0, j0) * wx;
|
||||
let bot = at(i0, j0 + 1.0) * (1.0 - wx) + at(i0 + 1.0, j0 + 1.0) * wx;
|
||||
(top * (1.0 - wy) + bot * wy).round() as u8
|
||||
} else {
|
||||
t.rgba[s + 3]
|
||||
};
|
||||
let o = (iy * px + ix) as usize;
|
||||
if a > 0 {
|
||||
anyv[o] = 255
|
||||
}
|
||||
if a >= ALPHA_REF {
|
||||
cover[o] = 255;
|
||||
colour[o * 3..o * 3 + 3].copy_from_slice(&t.rgba[s..s + 3]);
|
||||
owner[o * 3..o * 3 + 3].copy_from_slice(&gcol);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A HAIRLINE is an uncovered run at most 4 px wide with coverage on BOTH sides — a seam
|
||||
// between two tiles, as opposed to the large unauthored gaps (streets, the exterior group)
|
||||
// that are simply not part of any interior group's bake.
|
||||
let mut seam = colour.clone();
|
||||
let mut hair = 0usize;
|
||||
let mut runs: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
for axis in 0..2 {
|
||||
for a in 0..px {
|
||||
let mut i = 0u32;
|
||||
while i < px {
|
||||
let at = |k: u32| -> usize {
|
||||
if axis == 0 {
|
||||
(a * px + k) as usize
|
||||
} else {
|
||||
(k * px + a) as usize
|
||||
}
|
||||
};
|
||||
if cover[at(i)] == 0 {
|
||||
let mut j = i;
|
||||
while j < px && cover[at(j)] == 0 {
|
||||
j += 1
|
||||
}
|
||||
let len = (j - i) as usize;
|
||||
if len <= 4 && i > 0 && j < px {
|
||||
hair += len;
|
||||
*runs.entry(len).or_default() += 1;
|
||||
for k in i..j {
|
||||
let o = at(k) * 3;
|
||||
seam[o] = 255;
|
||||
seam[o + 1] = 0;
|
||||
seam[o + 2] = 255;
|
||||
}
|
||||
}
|
||||
i = j;
|
||||
} else {
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("hairline px (uncovered run <=4 with coverage both sides): {hair} run-length histogram {runs:?}");
|
||||
image::save_buffer(
|
||||
format!("{out}/mm_seam.png"),
|
||||
&seam,
|
||||
px,
|
||||
px,
|
||||
image::ColorType::Rgb8,
|
||||
)?;
|
||||
// The VISIBLE DISC — what the blit actually shows: radius `MM_DISC` yards about the player.
|
||||
// The reference capture reports 95.84% painted / 4.16% clear over exactly this.
|
||||
if let Ok(v) = std::env::var("MM_DISC") {
|
||||
let rd: f32 = v.parse().unwrap();
|
||||
let c0 = px as f32 * 0.5;
|
||||
let (mut inside, mut painted) = (0usize, 0usize);
|
||||
for iy in 0..px {
|
||||
for ix in 0..px {
|
||||
let d = ((ix as f32 + 0.5 - c0).powi(2) + (iy as f32 + 0.5 - c0).powi(2)).sqrt()
|
||||
* YD_PER_TEXEL;
|
||||
if d > rd {
|
||||
continue;
|
||||
}
|
||||
inside += 1;
|
||||
if cover[(iy * px + ix) as usize] != 0 {
|
||||
painted += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"DISC r={rd} yd: {:.2}% painted, {:.2}% on the clear",
|
||||
100.0 * painted as f32 / inside as f32,
|
||||
100.0 * (inside - painted) as f32 / inside as f32
|
||||
);
|
||||
}
|
||||
|
||||
let holes = cover.iter().filter(|&&c| c == 0).count();
|
||||
let recoverable = cover
|
||||
.iter()
|
||||
.zip(&anyv)
|
||||
.filter(|(&c, &a)| c == 0 && a > 0)
|
||||
.count();
|
||||
println!("{px}x{px} px @ {YD_PER_TEXEL} yd: {holes} uncovered ({:.2}%), of which {recoverable} have art the alpha test cut ({:.1}%)",
|
||||
100.0 * holes as f32 / (px * px) as f32, 100.0 * recoverable as f32 / holes.max(1) as f32);
|
||||
image::save_buffer(
|
||||
format!("{out}/mm_colour.png"),
|
||||
&colour,
|
||||
px,
|
||||
px,
|
||||
image::ColorType::Rgb8,
|
||||
)?;
|
||||
image::save_buffer(
|
||||
format!("{out}/mm_cover.png"),
|
||||
&cover,
|
||||
px,
|
||||
px,
|
||||
image::ColorType::L8,
|
||||
)?;
|
||||
image::save_buffer(
|
||||
format!("{out}/mm_any.png"),
|
||||
&anyv,
|
||||
px,
|
||||
px,
|
||||
image::ColorType::L8,
|
||||
)?;
|
||||
image::save_buffer(
|
||||
format!("{out}/mm_owner.png"),
|
||||
&owner,
|
||||
px,
|
||||
px,
|
||||
image::ColorType::Rgb8,
|
||||
)?;
|
||||
eprintln!("wrote {out}/mm_{{colour,cover,any,owner}}.png");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! TEMP (B141/N05): what the WMO's liquid actually looks like around a pin — which groups hold
|
||||
//! water there, their MOGP flags/liquid type, their MLIQ base heights, and whether two surfaces
|
||||
//! abut, overlap or step. Run: `cargo run -p benilla-formats --example mm_water_pin -- x y z [r]`.
|
||||
use std::io::Cursor;
|
||||
|
||||
const MAP_CENTER: f32 = 17066.666;
|
||||
|
||||
fn model_to_world(rot_deg: [f32; 3], p: [f32; 3]) -> [f32; 3] {
|
||||
let (rx, ry, rz) = (
|
||||
rot_deg[0].to_radians(),
|
||||
rot_deg[1].to_radians(),
|
||||
rot_deg[2].to_radians(),
|
||||
);
|
||||
let rot_x = |p: [f32; 3], a: f32| {
|
||||
let (s, c) = a.sin_cos();
|
||||
[p[0], p[1] * c - p[2] * s, p[1] * s + p[2] * c]
|
||||
};
|
||||
let rot_y = |p: [f32; 3], a: f32| {
|
||||
let (s, c) = a.sin_cos();
|
||||
[p[0] * c + p[2] * s, p[1], -p[0] * s + p[2] * c]
|
||||
};
|
||||
let rot_z = |p: [f32; 3], a: f32| {
|
||||
let (s, c) = a.sin_cos();
|
||||
[p[0] * c - p[1] * s, p[0] * s + p[1] * c, p[2]]
|
||||
};
|
||||
let p = rot_x(p, rz - std::f32::consts::FRAC_PI_2);
|
||||
let p = rot_z(p, -rx);
|
||||
let p = rot_y(p, ry - std::f32::consts::PI);
|
||||
rot_x(p, std::f32::consts::FRAC_PI_2)
|
||||
}
|
||||
fn world_to_model(rot_deg: [f32; 3], p: [f32; 3]) -> [f32; 3] {
|
||||
let (rx, ry, rz) = (
|
||||
rot_deg[0].to_radians(),
|
||||
rot_deg[1].to_radians(),
|
||||
rot_deg[2].to_radians(),
|
||||
);
|
||||
let rot_x = |p: [f32; 3], a: f32| {
|
||||
let (s, c) = a.sin_cos();
|
||||
[p[0], p[1] * c - p[2] * s, p[1] * s + p[2] * c]
|
||||
};
|
||||
let rot_y = |p: [f32; 3], a: f32| {
|
||||
let (s, c) = a.sin_cos();
|
||||
[p[0] * c + p[2] * s, p[1], -p[0] * s + p[2] * c]
|
||||
};
|
||||
let rot_z = |p: [f32; 3], a: f32| {
|
||||
let (s, c) = a.sin_cos();
|
||||
[p[0] * c - p[1] * s, p[0] * s + p[1] * c, p[2]]
|
||||
};
|
||||
let p = rot_x(p, -std::f32::consts::FRAC_PI_2);
|
||||
let p = rot_y(p, -(ry - std::f32::consts::PI));
|
||||
let p = rot_z(p, rx);
|
||||
rot_x(p, -(rz - std::f32::consts::FRAC_PI_2))
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let a: Vec<String> = std::env::args().skip(1).collect();
|
||||
let pin = [
|
||||
a.first().map_or(-8796.83, |s| s.parse().unwrap()),
|
||||
a.get(1).map_or(744.25, |s| s.parse().unwrap()),
|
||||
a.get(2).map_or(97.83, |s| s.parse().unwrap()),
|
||||
];
|
||||
let r: f32 = a.get(3).map_or(60.0, |s| s.parse().unwrap());
|
||||
|
||||
let data = benilla_formats::wow_data().unwrap();
|
||||
let mut chain = benilla_formats::open_chain(&data)?;
|
||||
let (tc, tr) = benilla_formats::world_to_tile(pin[0], pin[1]);
|
||||
let bytes = chain.read_file(&format!("World\\Maps\\Azeroth\\Azeroth_{tc}_{tr}.adt"))?;
|
||||
let benilla_adt::ParsedAdt::Root(adt) = benilla_adt::parse_adt(&mut Cursor::new(&*bytes))?;
|
||||
let mut best: Option<(String, [f32; 3], [f32; 3], f32)> = None;
|
||||
for p in &adt.wmo_placements {
|
||||
let Some(model) = adt.wmos.get(p.name_id as usize) else {
|
||||
continue;
|
||||
};
|
||||
let o = [
|
||||
MAP_CENTER - p.position[2],
|
||||
MAP_CENTER - p.position[0],
|
||||
p.position[1],
|
||||
];
|
||||
let d = (o[0] - pin[0]).hypot(o[1] - pin[1]);
|
||||
if best.as_ref().is_none_or(|b| d < b.3) {
|
||||
best = Some((model.replace('/', "\\"), o, p.rotation, d));
|
||||
}
|
||||
}
|
||||
let (model, origin, rot, _) = best.unwrap();
|
||||
let pm = world_to_model(
|
||||
rot,
|
||||
[pin[0] - origin[0], pin[1] - origin[1], pin[2] - origin[2]],
|
||||
);
|
||||
println!("{model} pin in model space {pm:?}");
|
||||
let stem = model.trim_end_matches(".wmo").to_string();
|
||||
let root = benilla_formats::parse_wmo_root(&chain.read_file(&format!("{stem}.wmo"))?)?;
|
||||
|
||||
let mut surfaces: Vec<(usize, benilla_formats::LiquidMesh, u32)> = Vec::new();
|
||||
for (gi, info) in root.group_infos().iter().enumerate() {
|
||||
if info.bbox_min[0] > pm[0] + r
|
||||
|| info.bbox_max[0] < pm[0] - r
|
||||
|| info.bbox_min[1] > pm[1] + r
|
||||
|| info.bbox_max[1] < pm[1] - r
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(gb) = chain.read_file(&format!("{stem}_{gi:03}.wmo")) else {
|
||||
continue;
|
||||
};
|
||||
let Some(h) = benilla_formats::wmo_group_header(&gb) else {
|
||||
continue;
|
||||
};
|
||||
let liq = benilla_formats::wmo_group_liquid_mesh(&gb);
|
||||
let Some(m) = liq else { continue };
|
||||
let wet = m.wet.iter().filter(|w| **w).count();
|
||||
if wet == 0 {
|
||||
continue;
|
||||
}
|
||||
let (mut zlo, mut zhi) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||
let (mut xlo, mut xhi, mut ylo, mut yhi) = (
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
);
|
||||
let (cols, rows) = (m.grid[0] as usize, m.grid[1] as usize);
|
||||
for j in 0..rows.saturating_sub(1) {
|
||||
for i in 0..cols.saturating_sub(1) {
|
||||
if !m.wet[j * (cols - 1) + i] {
|
||||
continue;
|
||||
}
|
||||
for (di, dj) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
|
||||
let p = m.positions[(j + dj) * cols + i + di];
|
||||
zlo = zlo.min(p[2]);
|
||||
zhi = zhi.max(p[2]);
|
||||
xlo = xlo.min(p[0]);
|
||||
xhi = xhi.max(p[0]);
|
||||
ylo = ylo.min(p[1]);
|
||||
yhi = yhi.max(p[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
surfaces.push((gi, m.clone(), h.flags));
|
||||
let w = model_to_world(rot, [0.5 * (xlo + xhi), 0.5 * (ylo + yhi), zhi]);
|
||||
println!(
|
||||
" g{gi:3} flags 0x{:x}{} liq {:?} groupLiquid 0x{:x} grid {}x{} wet {wet}\n model x[{xlo:.1},{xhi:.1}] y[{ylo:.1},{yhi:.1}] z[{zlo:.2},{zhi:.2}] .go xyz {:.2} {:.2} {:.2}",
|
||||
h.flags,
|
||||
if h.flags & 0x8 != 0 { " EXTERIOR" } else if h.flags & 0x40 != 0 { " EXT_LIT" } else { " interior" },
|
||||
m.kind, h.group_liquid, cols, rows,
|
||||
origin[0] + w[0], origin[1] + w[1], origin[2] + w[2] + 2.0,
|
||||
);
|
||||
}
|
||||
|
||||
// How many DISTINCT groups claim each point as wet? Two coplanar surfaces over the same ground
|
||||
// are drawn twice — a transparent water quad composited twice is visibly darker, and at equal
|
||||
// depth they also z-fight. Sampled on a 0.25 yd lattice over the pin's window.
|
||||
let step = 0.25f32;
|
||||
let n = (2.0 * r / step) as i32;
|
||||
let mut hist = std::collections::BTreeMap::<usize, usize>::new();
|
||||
let mut gated_hist = std::collections::BTreeMap::<usize, usize>::new();
|
||||
let mut worst: Option<(usize, [f32; 3])> = None;
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
let mx = pm[0] - r + (i as f32 + 0.5) * step;
|
||||
let my = pm[1] - r + (j as f32 + 0.5) * step;
|
||||
let mut k = 0;
|
||||
let mut gated = 0;
|
||||
let mut owner: Option<usize> = None;
|
||||
for (gi, m, _) in &surfaces {
|
||||
let Some(c) = cell_at(m, mx, my) else {
|
||||
continue;
|
||||
};
|
||||
if !m.wet[c] {
|
||||
continue;
|
||||
}
|
||||
k += 1;
|
||||
// The gate: an unshared cell always draws; a `0x80` cell only for the first
|
||||
// (lowest-index) claimant.
|
||||
if !m.shared[c] {
|
||||
gated += 1;
|
||||
} else if owner.is_none_or(|o| o == *gi) {
|
||||
owner = Some(*gi);
|
||||
gated += 1;
|
||||
}
|
||||
}
|
||||
*hist.entry(k).or_default() += 1;
|
||||
*gated_hist.entry(gated).or_default() += 1;
|
||||
if k >= 2 && worst.as_ref().is_none_or(|w| k > w.0) {
|
||||
worst = Some((k, model_to_world(rot, [mx, my, -6.48])));
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"\nwet-surface overlap over a {}x{} lattice at {step} yd: {hist:?}",
|
||||
n, n
|
||||
);
|
||||
println!(" with the MLIQ 0x80 shared-tile gate (lowest claimant wins): {gated_hist:?}");
|
||||
if let Some((k, w)) = worst {
|
||||
println!(
|
||||
" deepest overlap {k} surfaces at .go xyz {:.2} {:.2} {:.2}",
|
||||
origin[0] + w[0],
|
||||
origin[1] + w[1],
|
||||
origin[2] + w[2] + 2.0
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The cell index `(mx, my)` falls in, if it is inside the grid at all.
|
||||
fn cell_at(m: &benilla_formats::LiquidMesh, mx: f32, my: f32) -> Option<usize> {
|
||||
let (cols, rows) = (m.grid[0] as usize, m.grid[1] as usize);
|
||||
if cols < 2 || rows < 2 {
|
||||
return None;
|
||||
}
|
||||
let o = m.positions[0];
|
||||
let sx = (m.positions[1][0] - o[0]).abs().max(1e-6);
|
||||
let sy = (m.positions[cols][1] - o[1]).abs().max(1e-6);
|
||||
let (x0, y0) = (
|
||||
o[0].min(m.positions[cols * rows - 1][0]),
|
||||
o[1].min(m.positions[cols * rows - 1][1]),
|
||||
);
|
||||
let i = ((mx - x0) / sx).floor();
|
||||
let j = ((my - y0) / sy).floor();
|
||||
if i < 0.0 || j < 0.0 || i as usize >= cols - 1 || j as usize >= rows - 1 {
|
||||
return None;
|
||||
}
|
||||
Some(j as usize * (cols - 1) + i as usize)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! TEMP (B141): what fraction of a captured minimap disc is the composite's black clear.
|
||||
//! `cargo run -p benilla-formats --example png_disc -- <png> [cx cy r]` — defaults to the whole
|
||||
//! image's inscribed circle, which is what the `WOW_MM_PROBE` crop is.
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let a: Vec<String> = std::env::args().skip(1).collect();
|
||||
let img = image::open(&a[0])?.to_rgb8();
|
||||
let (w, h) = img.dimensions();
|
||||
let cx = a.get(1).map_or(w as f32 * 0.5, |s| s.parse().unwrap());
|
||||
let cy = a.get(2).map_or(h as f32 * 0.5, |s| s.parse().unwrap());
|
||||
let r = a
|
||||
.get(3)
|
||||
.map_or(w.min(h) as f32 * 0.5 - 2.0, |s| s.parse().unwrap());
|
||||
let (mut inside, mut black) = (0usize, 0usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt() > r {
|
||||
continue;
|
||||
}
|
||||
inside += 1;
|
||||
let p = img.get_pixel(x, y).0;
|
||||
if p[0] < 16 && p[1] < 16 && p[2] < 16 {
|
||||
black += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{}: {:.2}% of the disc is clear-black ({black}/{inside})",
|
||||
a[0],
|
||||
100.0 * black as f32 / inside as f32
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -136,6 +136,21 @@ pub struct LiquidMesh {
|
||||
/// truth for containment — a liquid grid is sparse (nibble `0xf` = hole), so its bounding box
|
||||
/// routinely spans dry ground it never touches (decision 0635).
|
||||
pub wet: Vec<bool>,
|
||||
/// Per-**cell**, parallel to [`Self::wet`]: the MLIQ tile flag's **`0x80`** bit — "a neighbouring
|
||||
/// group also claims this cell". Authored on **both** claimants, never on a cell only one group
|
||||
/// covers (measured over `Stormwind.wmo`: 190 of 2685 wet cells, each claimed by exactly two
|
||||
/// groups and flagged in both; zero overlap among the `0x80`-clear cells — wow-re
|
||||
/// `terrain/scratch/wmo-liquid-shared-tile-gate.md`).
|
||||
///
|
||||
/// The reference's strip builder emits a tile iff `(f & 0xf) != 0xf` **and** (`f & 0x80 == 0`
|
||||
/// **or** this group won the gate) — a per-frame 2-colouring of the portal graph that lets
|
||||
/// exactly ONE of the two claimants draw. Without it both surfaces draw, coplanar, and the
|
||||
/// overlap band composites the translucent water twice: the visible line across the Stormwind
|
||||
/// canals (B141's water half). Resolved model-wide in `benilla_assets::wmo`, not here — a group
|
||||
/// file cannot see its neighbours.
|
||||
///
|
||||
/// Always `false` on the ADT path: MCLQ has no such bit and no such overlap.
|
||||
pub shared: Vec<bool>,
|
||||
/// Vertex positions `[x, y, z]` in WoW yards — `cols·rows` entries, row-major.
|
||||
pub positions: Vec<[f32; 3]>,
|
||||
/// Texture UVs parallel to `positions`. No scroll for water.
|
||||
@@ -324,6 +339,7 @@ pub(crate) fn build_liquid_mesh(mclq: &MclqChunk, position: [f32; 3]) -> Option<
|
||||
|
||||
Some(LiquidMesh {
|
||||
grid: [GRID as u32, GRID as u32],
|
||||
shared: vec![false; wet.len()],
|
||||
wet,
|
||||
positions,
|
||||
uvs,
|
||||
@@ -335,6 +351,41 @@ pub(crate) fn build_liquid_mesh(mclq: &MclqChunk, position: [f32; 3]) -> Option<
|
||||
})
|
||||
}
|
||||
|
||||
impl LiquidMesh {
|
||||
/// Drop the cells `keep` rejects (indexed like [`Self::wet`]), rebuilding [`Self::indices`].
|
||||
/// The vertex arrays are left alone — a vertex no triangle references costs nothing to keep and
|
||||
/// the grid stays addressable by cell index.
|
||||
///
|
||||
/// This is how the MLIQ [`Self::shared`] gate is applied once the owning model can see every
|
||||
/// group at once.
|
||||
pub fn retain_cells(&mut self, keep: impl Fn(usize) -> bool) {
|
||||
let (cols, rows) = (self.grid[0] as usize, self.grid[1] as usize);
|
||||
let (xt, yt) = (cols.saturating_sub(1), rows.saturating_sub(1));
|
||||
let mut dropped = false;
|
||||
for c in 0..self.wet.len() {
|
||||
if self.wet[c] && !keep(c) {
|
||||
self.wet[c] = false;
|
||||
dropped = true;
|
||||
}
|
||||
}
|
||||
if !dropped {
|
||||
return;
|
||||
}
|
||||
self.indices.clear();
|
||||
for ty in 0..yt {
|
||||
for tx in 0..xt {
|
||||
if !self.wet[ty * xt + tx] {
|
||||
continue;
|
||||
}
|
||||
let tl = (ty * cols + tx) as u32;
|
||||
let (tr, bl) = (tl + 1, ((ty + 1) * cols + tx) as u32);
|
||||
let br = bl + 1;
|
||||
self.indices.extend_from_slice(&[tl, bl, br, tl, br, tr]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
|
||||
@@ -334,6 +334,9 @@ fn build_wmo_liquid_mesh(lq: &WmoLiquid, group_liquid: u32) -> Option<LiquidMesh
|
||||
// the substitution below.
|
||||
let mut indices = Vec::with_capacity(xt * yt * 6);
|
||||
let mut wet = vec![false; xt * yt];
|
||||
// The `0x80` "a neighbour also claims this cell" bit — kept per cell, gated model-wide later
|
||||
// (see `LiquidMesh::shared`).
|
||||
let mut shared = vec![false; xt * yt];
|
||||
let mut drawn = vec![false; xv * yv];
|
||||
for ty in 0..yt {
|
||||
for tx in 0..xt {
|
||||
@@ -341,6 +344,7 @@ fn build_wmo_liquid_mesh(lq: &WmoLiquid, group_liquid: u32) -> Option<LiquidMesh
|
||||
continue; // hole — no liquid on this tile
|
||||
}
|
||||
wet[ty * xt + tx] = true;
|
||||
shared[ty * xt + tx] = lq.tile_flags[ty * xt + tx] & 0x80 != 0;
|
||||
let tl = (ty * xv + tx) as u32;
|
||||
let tr = tl + 1;
|
||||
let bl = ((ty + 1) * xv + tx) as u32;
|
||||
@@ -412,6 +416,7 @@ fn build_wmo_liquid_mesh(lq: &WmoLiquid, group_liquid: u32) -> Option<LiquidMesh
|
||||
Some(LiquidMesh {
|
||||
grid: [xv as u32, yv as u32],
|
||||
wet,
|
||||
shared,
|
||||
positions,
|
||||
uvs,
|
||||
depths,
|
||||
|
||||
@@ -1007,6 +1007,7 @@ mod tests {
|
||||
LiquidMesh {
|
||||
grid: [2, 2],
|
||||
wet: vec![true],
|
||||
shared: vec![false],
|
||||
positions: vec![
|
||||
[0.0, 0.0, z],
|
||||
[10.0, 0.0, z],
|
||||
|
||||
Reference in New Issue
Block a user