Skip to content

Commit ce124ee

Browse files
dalkiaclaude
andcommitted
texcmp: normalize normal-map packing before diffing; angle-based verdicts
A texture stored as a plain RGB normal map and the same texture in Unity's AG-swizzle (DXT5nm convention: R pinned to 255, X in alpha, B copying G) render identically through UnpackNormalmapRGorAG, but raw-decode as blue vs pink — texcmp diffed the raw channels and reported every pixel different (ppm=1e6, "visible") for render-identical bundles. Observed live on stylizedWood_v01_normal.png pairs where upstream v49 swizzled a texture our per-entity usage classification stored plain. When either side of a matched texture shows the AG signature (R>=250 on 99% of pixels, varying alpha, B~G), both sides are re-expressed as reconstructed normals (X, Y, sqrt(1-X^2-Y^2)) before the pixel diff, the packing per side is recorded (normalPacking field + note), and imperceptibility uses the perceptual quantity for normals: pixels tilted more than 5 degrees must cover at most 200ppm of the image (mirrors the render amnesty shape). maxNormalAngleDeg / normalAngleOverPpm are emitted per texture. Verified against live ab-cdn v49 bundles: - packing-mismatch pair (same content): ppm 1e6 "visible" -> imperceptible (137ppm of pixels over 5deg, max 16.5deg — cross-encoder block noise) - both-AG pair (same content): ppm 19k "visible" -> imperceptible (max angle 1.89deg, 0ppm over) - genuinely different normal maps: stays visible (633ppm over 5deg) - plain color textures: untouched code path, verdicts unchanged Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0d5e9fe commit ce124ee

1 file changed

Lines changed: 118 additions & 0 deletions

File tree

crate/examples/texcmp.rs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,85 @@ fn extract_textures(path: &str) -> Result<Vec<Tex>, String> {
204204
Ok(out)
205205
}
206206

207+
/// Detect Unity's AG-swizzled normal-map packing (DXT5nm convention carried
208+
/// into BC7): R pinned to ~255 across the image, X riding in a varying alpha,
209+
/// B a copy of G. Raw-decoded it looks pink; a plain normal map looks blue —
210+
/// both render identically through UnpackNormalmapRGorAG (X = R×A), so a
211+
/// packing difference alone must not count as a pixel difference.
212+
fn ag_swizzled(rgba: &[u8]) -> bool {
213+
let n = rgba.len() / 4;
214+
if n == 0 {
215+
return false;
216+
}
217+
let (mut r_hi, mut a_varies, mut bg_close) = (0usize, 0usize, 0usize);
218+
for p in rgba.chunks_exact(4) {
219+
if p[0] >= 250 {
220+
r_hi += 1;
221+
}
222+
if p[3] <= 250 {
223+
a_varies += 1;
224+
}
225+
if (p[2] as i32 - p[1] as i32).unsigned_abs() <= 4 {
226+
bg_close += 1;
227+
}
228+
}
229+
r_hi * 100 >= n * 99 && a_varies * 100 >= n * 50 && bg_close * 100 >= n * 90
230+
}
231+
232+
/// Re-express a normal map as reconstructed vectors `(X, Y, √(1−X²−Y²), 255)`
233+
/// regardless of input packing, so two packings of the same normals compare
234+
/// equal and real normal differences still surface.
235+
fn unpack_normals(rgba: &[u8], swizzled: bool) -> Vec<u8> {
236+
let mut out = Vec::with_capacity(rgba.len());
237+
for p in rgba.chunks_exact(4) {
238+
let (x, y) = if swizzled { (p[3], p[1]) } else { (p[0], p[1]) };
239+
let fx = x as f32 / 127.5 - 1.0;
240+
let fy = y as f32 / 127.5 - 1.0;
241+
let fz = (1.0 - fx * fx - fy * fy).max(0.0).sqrt();
242+
let z = ((fz * 0.5 + 0.5) * 255.0).round().clamp(0.0, 255.0) as u8;
243+
out.extend_from_slice(&[x, y, z, 255]);
244+
}
245+
out
246+
}
247+
248+
/// Angular deviation between two reconstructed-normal buffers:
249+
/// `(max_degrees, ppm_of_pixels_tilted_more_than_NORMAL_ANGLE_DEG)`.
250+
/// Channel deltas overstate normal-map noise perceptually; what matters is
251+
/// how far normals tilt and over how much area.
252+
fn normal_angle_stats(a: &[u8], b: &[u8]) -> (f64, f64) {
253+
let threshold_dot = NORMAL_ANGLE_DEG.to_radians().cos();
254+
let mut worst_dot = 1.0f64;
255+
let mut over = 0usize;
256+
let n = (a.len() / 4).max(1);
257+
for (pa, pb) in a.chunks_exact(4).zip(b.chunks_exact(4)) {
258+
let v = |p: &[u8]| {
259+
let x = p[0] as f64 / 127.5 - 1.0;
260+
let y = p[1] as f64 / 127.5 - 1.0;
261+
let z = p[2] as f64 / 127.5 - 1.0;
262+
let len = (x * x + y * y + z * z).sqrt().max(1e-6);
263+
(x / len, y / len, z / len)
264+
};
265+
let (ax, ay, az) = v(pa);
266+
let (bx, by, bz) = v(pb);
267+
let dot = (ax * bx + ay * by + az * bz).clamp(-1.0, 1.0);
268+
if dot < worst_dot {
269+
worst_dot = dot;
270+
}
271+
if dot < threshold_dot {
272+
over += 1;
273+
}
274+
}
275+
(worst_dot.acos().to_degrees(), over as f64 * 1e6 / n as f64)
276+
}
277+
278+
/// Normal-map imperceptibility, mirroring the render amnesty shape
279+
/// ("Δ>8 ≤200ppm"): pixels whose normal tilts more than NORMAL_ANGLE_DEG
280+
/// must cover at most NORMAL_ANGLE_AMNESTY_PPM of the image. BC-encoder
281+
/// block noise sits well inside this; an actually different normal map
282+
/// blows past it over broad areas.
283+
const NORMAL_ANGLE_DEG: f64 = 5.0;
284+
const NORMAL_ANGLE_AMNESTY_PPM: f64 = 200.0;
285+
207286
fn rank(class: &str) -> i32 {
208287
match class {
209288
"identical" => 0,
@@ -406,6 +485,29 @@ fn process(task: &serde_json::Value) -> serde_json::Value {
406485
t.name
407486
));
408487
} else {
488+
// Normal-map awareness: if either side carries the AG
489+
// swizzle, compare reconstructed normal vectors instead
490+
// of raw channels — the packings render identically, so
491+
// only actual normal differences may count.
492+
let (sw_ours, sw_up) = (ag_swizzled(ra), ag_swizzled(rb));
493+
let (norm_a, norm_b);
494+
let (ra, rb): (&Vec<u8>, &Vec<u8>) = if sw_ours || sw_up {
495+
norm_a = unpack_normals(ra, sw_ours);
496+
norm_b = unpack_normals(rb, sw_up);
497+
let pack = |s: bool| if s { "ag" } else { "plain" };
498+
notes.push(format!(
499+
"normal-map:{}:packing ours={} upstream={}",
500+
t.name,
501+
pack(sw_ours),
502+
pack(sw_up)
503+
));
504+
tex["normalPacking"] = serde_json::json!({
505+
"ours": pack(sw_ours), "upstream": pack(sw_up),
506+
});
507+
(&norm_a, &norm_b)
508+
} else {
509+
(ra, rb)
510+
};
409511
let n = wa * ha;
410512
let mut px: i64 = 0;
411513
let mut mcd: i64 = 0;
@@ -432,6 +534,14 @@ fn process(task: &serde_json::Value) -> serde_json::Value {
432534
.map(|s| (s / n as f64 * 10000.0).round() / 10000.0)
433535
.collect();
434536
let mean_max = means.iter().cloned().fold(0.0, f64::max);
537+
let normal_angle: Option<(f64, f64)> = if sw_ours || sw_up {
538+
let (deg, over_ppm) = normal_angle_stats(ra, rb);
539+
tex["maxNormalAngleDeg"] = ((deg * 100.0).round() / 100.0).into();
540+
tex["normalAngleOverPpm"] = ((over_ppm * 10.0).round() / 10.0).into();
541+
Some((deg, over_ppm))
542+
} else {
543+
None
544+
};
435545
tclass = if px == 0 {
436546
"identical-decode".into()
437547
} else if ppm <= 200.0 {
@@ -443,6 +553,14 @@ fn process(task: &serde_json::Value) -> serde_json::Value {
443553
t.name, ppm
444554
));
445555
"imperceptible".into()
556+
} else if normal_angle.is_some_and(|(_, over)| over <= NORMAL_ANGLE_AMNESTY_PPM)
557+
{
558+
let (deg, over) = normal_angle.unwrap_or((0.0, 0.0));
559+
notes.push(format!(
560+
"imperceptible-by=normalAngle:{}:>{NORMAL_ANGLE_DEG}deg at {over:.0}ppm (max {deg:.2}deg)",
561+
t.name
562+
));
563+
"imperceptible".into()
446564
} else {
447565
"visible".into()
448566
};

0 commit comments

Comments
 (0)