Preserve terrain grain across HD edge and corner joins
This commit is contained in:
1 parent
b3d0013b65
commit
ab23255d2c
3 files changed
+130
-27
No files matched your search
@@ -10,7 +10,7 @@ Reviewed native HD tiles are fetched lazily from `HdAssets` only for visible pla
|
||||
|
||||
Each original terrain ID needs one canonical full opaque 32×32 logical image, including IDs stored outside the named sprite folders. A generation job should include the exact original source, all cardinal neighbors encountered in mission layouts, actual 3×3 context samples and any intact/destroyed partner. Preserve coast crossings, road widths, building footprints, color families and edge geometry. Fine texture may improve; a displaced coastline or invented solid structure needs correction before acceptance.
|
||||
|
||||
A canonical tile may occur beside several different neighbors. The renderer prepares one pristine joined HD layer for all accepted visible tiles before resolving their edges. Adjacent HD tiles share a detailed indexed edge color where their original material class and source tones agree; four adjacent HD tiles share a common corner color. Distinct original coastline, road or material boundaries remain sharp. Each tile reaches its own full detail two logical pixels inward using integer smoothstep weights.
|
||||
A canonical tile may occur beside several different neighbors. The renderer prepares one pristine joined HD layer for all accepted visible tiles before resolving their edges. Where original material classes and source tones agree, adjacent HD tiles share corresponding pristine detail at each mirrored distance from the join. Four-tile corners apply this rule in both axes. This preserves fine grain rather than stretching a single outer-edge sample into repeated ridges. Distinct original coastline, road or material boundaries remain sharp. Each tile reaches its own full detail two logical pixels inward using integer smoothstep weights.
|
||||
|
||||
At an HD/original join, the replacement transitions to the unchanged joined original context instead. This preserves mixed coverage and source fallback. Full HD neighborhoods retain detailed colors across their internal edges, avoiding the grid produced by forcing every tile back to low-resolution source colors. Source geometry remains a generation and review requirement; edge blending does not repair a shifted coastline.
|
||||
|
||||
@@ -34,9 +34,11 @@ Run the portable checks with:
|
||||
LD_LIBRARY_PATH=/home/quintine/.local/lib cargo test --locked --manifest-path RustRaptor/Cargo.toml --test hd_terrain
|
||||
```
|
||||
|
||||
The twelve portable tests cover shared edges and four-tile corners at all four scales, fully remastered neighborhoods without a source-pixel grid, hard material boundaries, mixed HD/original joins, overlay order independence, negative gutters, fractional scroll offsets, whole-tile rejection, palette masks, bounded cache memory, logical dimensions, native destruction settling and source-bound lazy loading of distinct intact/destroyed images. The row-aligned strip matches the previous joined SD viewport pixel-for-pixel at all 32 scroll phases, four scales, five interpolation values and negative row origins. Presentation work between simulation steps is compared against an untouched control for world state, RNG, damage and rewards.
|
||||
The thirteen portable tests cover shared edges and four-tile corners at all four scales, fully remastered neighborhoods without a source-pixel grid, fine-grain preservation across join bands, hard material boundaries, mixed HD/original joins, overlay order independence, negative gutters, fractional scroll offsets, whole-tile rejection, palette masks, bounded cache memory, logical dimensions, native destruction settling and source-bound lazy loading of distinct intact/destroyed images. The row-aligned strip matches the previous joined SD viewport pixel-for-pixel at all 32 scroll phases, four scales, five interpolation values and negative row origins. Presentation work between simulation steps is compared against an untouched control for world state, RNG, damage and rewards.
|
||||
|
||||
Two additional original-data checks verified all 27 original missions and 4,217 distinct declared intact/destroyed terrain IDs are opaque 32×32 canvases, and produced a compositor comparison from the unregistered shoreline draft. The comparison is under ignored `.reference/terrain-hd/shoreline-draft-context-proof.png`: joined original Scale4x, the raw placement-specific HD draft, and the actual indexed HD composition. This corrected the visible grid found in an earlier source-color edge transition. A separate ignored release timing test exercises 72 visible HD placements with the 1280×816 gutter canvas. On the development machine, its 50 warm frames measured about 5.0 ms median and 6.6 ms p95 after edge/corner precomputation; cold composition took 8.9 ms. These timings cover the compositor only, excluding asset loading, SDL and the rest of the scene.
|
||||
Two additional original-data checks verified all 27 original missions and 4,217 distinct declared intact/destroyed terrain IDs are opaque 32×32 canvases, and produced a compositor comparison from the unregistered shoreline draft. The comparison is under ignored `.reference/terrain-hd/shoreline-draft-context-proof.png`: joined original Scale4x, the raw placement-specific HD draft, and the actual indexed HD composition. This corrected the visible grid found in an earlier source-color edge transition. A separate ignored release timing test exercises 72 visible HD placements with the 1280×816 gutter canvas. With the distance-aware grain correction, its 50 warm compositions measured 8.97 ms median and 10.46 ms p95; cold composition took 10.79 ms. The earlier edge-sample version measured 5.0/6.6 ms but stretched grain into ridges. These timings cover strip rebuilding only, excluding asset loading, SDL and the rest of the scene; normal cached scrolling reuses the prepared strip.
|
||||
|
||||
Independent review inspected all 66 pages of 1,706 actual neighbor, context and destruction-partner cases, plus seven focused original/pristine/previous/fixed panels. The fix removes repeated industrial and surface-grain ridges without new material, corner or pipe drift. The corpus checked 14,928 overlay applications and 175,134,720 palette-class pixels; a cached native scene averaged 1.63 ms per draw. Evidence is in `.reference/terrain-hd/join-ridge/` and `.reference/hd-full-review/terrain-ridge-final-independent.json`. Artwork acceptance and original-only destroyed-state fallback remain separate from this compositor review.
|
||||
|
||||
The completed strip cache was also measured in the real OpenGL SDL game with HD Stormwall terrain, enemies, projectiles, ship, shadows and HUD. Across 114 measured frames, 1280×800 presentation took 4.08 ms median and 9.86 ms p95 of game work, compared with 15.55/18.64 ms before strip reuse. Fullscreen 1920×1080 with aspect correction took 4.27/9.10 ms; total frame times were 5.86/9.28 ms. A separate cached original-mission `Host` scene averaged 2.97 ms across 60 draws. These local timings use a dummy audio output and describe this scene and machine, not every mission. Both SDL screenshots were inspected for clipping, coastline joins, low-resolution tile grids, foreground order and aspect presentation. Evidence is under `.reference/terrain-hd/stormwall-hd-prepared*.png` and the adjacent timing files.
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ impl TerrainHd {
|
||||
.min(tile.image.width / 2)
|
||||
.min(tile.image.height / 2)
|
||||
.max(1);
|
||||
let edges = self.tile_edges(tile, &accepted, &detailed, context, scale, palette);
|
||||
let edges = self.tile_edges(tile, &accepted, &detailed, context, scale, band, palette);
|
||||
let weights: [usize; 8] = std::array::from_fn(|distance| edge_weight(distance, band));
|
||||
for sy in 0..tile.image.height {
|
||||
let yy = top as i64 + sy as i64;
|
||||
@@ -236,14 +236,21 @@ impl TerrainHd {
|
||||
{
|
||||
let corner = (vside - 2) * 2 + hside;
|
||||
(
|
||||
edges.corners[corner].color(original, raw, palette),
|
||||
edges.corners[corner]
|
||||
.color(hdistance, vdistance, band, original, raw, palette),
|
||||
hdistance.min(vdistance),
|
||||
)
|
||||
} else if let Some((side, distance)) = horizontal {
|
||||
(edges.color(side, sy, original, raw, palette), distance)
|
||||
(
|
||||
edges.color(side, sy, distance, original, raw, palette),
|
||||
distance,
|
||||
)
|
||||
} else {
|
||||
let (side, distance) = vertical.unwrap();
|
||||
(edges.color(side, sx, original, raw, palette), distance)
|
||||
(
|
||||
edges.color(side, sx, distance, original, raw, palette),
|
||||
distance,
|
||||
)
|
||||
};
|
||||
image.pixels[destination] =
|
||||
self.transition(target, raw, weights[distance], palette);
|
||||
@@ -320,6 +327,7 @@ impl TerrainHd {
|
||||
detailed: &Sprite,
|
||||
context: &Sprite,
|
||||
scale: usize,
|
||||
band: usize,
|
||||
palette: &[[u8; 3]; 256],
|
||||
) -> EdgeTargets {
|
||||
let [width, height] = tile.logical_size;
|
||||
@@ -352,18 +360,29 @@ impl TerrainHd {
|
||||
} else {
|
||||
tile.image.width
|
||||
};
|
||||
(0..length)
|
||||
.map(|offset| {
|
||||
// Pair pristine detail at each mirrored distance across the join.
|
||||
// Repeating only the outer edge's sample throughout this band
|
||||
// stretches fine grain into ribs even when the source art joins.
|
||||
(0..length * band)
|
||||
.map(|sample| {
|
||||
let offset = sample / band;
|
||||
let distance = (sample % band) as i64;
|
||||
let points = match side {
|
||||
0 => [[left - 1, top + offset as i64], [left, top + offset as i64]],
|
||||
0 => [
|
||||
[left - 1 - distance, top + offset as i64],
|
||||
[left + distance, top + offset as i64],
|
||||
],
|
||||
1 => [
|
||||
[right - 1, top + offset as i64],
|
||||
[right, top + offset as i64],
|
||||
[right - 1 - distance, top + offset as i64],
|
||||
[right + distance, top + offset as i64],
|
||||
],
|
||||
2 => [
|
||||
[left + offset as i64, top - 1 - distance],
|
||||
[left + offset as i64, top + distance],
|
||||
],
|
||||
2 => [[left + offset as i64, top - 1], [left + offset as i64, top]],
|
||||
_ => [
|
||||
[left + offset as i64, bottom - 1],
|
||||
[left + offset as i64, bottom],
|
||||
[left + offset as i64, bottom - 1 - distance],
|
||||
[left + offset as i64, bottom + distance],
|
||||
],
|
||||
};
|
||||
self.shared_target(detailed, context, &points, palette)
|
||||
@@ -380,19 +399,33 @@ impl TerrainHd {
|
||||
let by = if vside == 2 { top } else { bottom };
|
||||
CornerTarget {
|
||||
complete,
|
||||
target: if complete {
|
||||
self.shared_target(
|
||||
detailed,
|
||||
context,
|
||||
&[[bx - 1, by - 1], [bx, by - 1], [bx - 1, by], [bx, by]],
|
||||
palette,
|
||||
)
|
||||
// The same distance-aware rule applies in both axes at four
|
||||
// HD tiles' intersection, avoiding a flat corner-color patch.
|
||||
targets: if complete {
|
||||
(0..band * band)
|
||||
.map(|sample| {
|
||||
let dx = (sample % band) as i64;
|
||||
let dy = (sample / band) as i64;
|
||||
self.shared_target(
|
||||
detailed,
|
||||
context,
|
||||
&[
|
||||
[bx - 1 - dx, by - 1 - dy],
|
||||
[bx + dx, by - 1 - dy],
|
||||
[bx - 1 - dx, by + dy],
|
||||
[bx + dx, by + dy],
|
||||
],
|
||||
palette,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
None
|
||||
Vec::new()
|
||||
},
|
||||
}
|
||||
});
|
||||
EdgeTargets {
|
||||
band,
|
||||
adjacent,
|
||||
sides,
|
||||
corners,
|
||||
@@ -479,6 +512,7 @@ impl TerrainHd {
|
||||
}
|
||||
|
||||
struct EdgeTargets {
|
||||
band: usize,
|
||||
adjacent: [bool; 4],
|
||||
sides: [Vec<Option<(u16, u16)>>; 4],
|
||||
corners: [CornerTarget; 4],
|
||||
@@ -488,6 +522,7 @@ impl EdgeTargets {
|
||||
&self,
|
||||
side: usize,
|
||||
offset: usize,
|
||||
distance: usize,
|
||||
original: u16,
|
||||
raw: u16,
|
||||
palette: &[[u8; 3]; 256],
|
||||
@@ -495,21 +530,29 @@ impl EdgeTargets {
|
||||
if !self.adjacent[side] {
|
||||
return original;
|
||||
}
|
||||
self.sides[side][offset]
|
||||
self.sides[side][offset * self.band + distance]
|
||||
.filter(|(_, source)| same_material(original, *source, palette))
|
||||
.map_or(raw, |(target, _)| target)
|
||||
}
|
||||
}
|
||||
struct CornerTarget {
|
||||
complete: bool,
|
||||
target: Option<(u16, u16)>,
|
||||
targets: Vec<Option<(u16, u16)>>,
|
||||
}
|
||||
impl CornerTarget {
|
||||
fn color(&self, original: u16, raw: u16, palette: &[[u8; 3]; 256]) -> u16 {
|
||||
fn color(
|
||||
&self,
|
||||
horizontal_distance: usize,
|
||||
vertical_distance: usize,
|
||||
band: usize,
|
||||
original: u16,
|
||||
raw: u16,
|
||||
palette: &[[u8; 3]; 256],
|
||||
) -> u16 {
|
||||
if !self.complete {
|
||||
return original;
|
||||
}
|
||||
self.target
|
||||
self.targets[vertical_distance * band + horizontal_distance]
|
||||
.filter(|(_, source)| same_material(original, *source, palette))
|
||||
.map_or(raw, |(target, _)| target)
|
||||
}
|
||||
|
||||
@@ -222,6 +222,64 @@ fn shared_hd_edges_preserve_original_hard_material_boundaries_and_mixed_fallback
|
||||
assert_eq!(at(&mixed.image, 100, 64), 180);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_hd_grain_is_preserved_across_edge_and_corner_bands() {
|
||||
for scale in [1, 2, 3, 4] {
|
||||
let width = 32 * scale;
|
||||
let context = solid(width * 2, width * 2, 40);
|
||||
let mut pristine = context.clone();
|
||||
let mut replacements = Vec::new();
|
||||
// Four detailed surfaces already agree at every mirrored distance
|
||||
// from their joins. Their grain must stay two-dimensional, including
|
||||
// the four-way corner, rather than become a repeated edge sample.
|
||||
for row in 0..2 {
|
||||
for column in 0..2 {
|
||||
let mut image = solid(width, width, 0);
|
||||
for y in 0..width {
|
||||
for x in 0..width {
|
||||
let dx = if column == 0 { width - 1 - x } else { x };
|
||||
let dy = if row == 0 { width - 1 - y } else { y };
|
||||
let color = 100 + ((dx * 17 + dy * 29 + dx * dy * 7) % 100) as u16;
|
||||
image.pixels[y * width + x] = color;
|
||||
pristine.pixels[(row * width + y) * pristine.width + column * width + x] =
|
||||
color;
|
||||
}
|
||||
}
|
||||
replacements.push(Replacement {
|
||||
image,
|
||||
position: [(column * 32) as i32, (row * 32) as i32],
|
||||
logical_size: [32, 32],
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut compositor = TerrainHd::default();
|
||||
let composed = compositor.compose(&context, &replacements, scale, &palette());
|
||||
let band = 2 * scale;
|
||||
for offset in 4 * scale..60 * scale {
|
||||
for depth in width - band..width + band {
|
||||
assert_eq!(
|
||||
at(&composed.image, depth, offset),
|
||||
at(&pristine, depth, offset),
|
||||
"compatible horizontal grain was stretched at scale {scale}"
|
||||
);
|
||||
assert_eq!(
|
||||
at(&composed.image, offset, depth),
|
||||
at(&pristine, offset, depth),
|
||||
"compatible vertical/corner grain was stretched at scale {scale}"
|
||||
);
|
||||
}
|
||||
}
|
||||
replacements.reverse();
|
||||
assert_eq!(
|
||||
composed.image,
|
||||
compositor
|
||||
.compose(&context, &replacements, scale, &palette())
|
||||
.image
|
||||
);
|
||||
assert_eq!(compositor.cache_bytes(), 2 * 1024 * 1024);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_gutters_and_fractional_scroll_move_the_completed_layer_together() {
|
||||
for scale in [1, 2, 3, 4] {
|
||||
|
||||
Reference in new issue
Block a user