From 653b5e5356295f5d2afa649b0c983a2eaf8b4c81 Mon Sep 17 00:00:00 2001 From: Richard Higgins Date: Fri, 2 Oct 2026 23:56:14 -0700 Subject: [PATCH 1/2] Reuse identical prop texture arrays within an asset owner --- src/polyworld/quadterrain.nim | 51 +++++++++++++++++++++--- tests/test_prop_texture_cache.nim | 66 +++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 tests/test_prop_texture_cache.nim diff --git a/src/polyworld/quadterrain.nim b/src/polyworld/quadterrain.nim index bb387aeb..586375bf 100644 --- a/src/polyworld/quadterrain.nim +++ b/src/polyworld/quadterrain.nim @@ -7,7 +7,7 @@ ## (prop models load from ../polyworld_data/terrain/). import - std/[os, random, strformat, strutils, tables], + std/[os, random, sha1, strformat, strutils, tables], chroma, gltf, opengl, pixie, pixie/internal, shady, vmath, assets, common, pathing, profiles, shadows, terrainblends, terrainmaps, terrainreliefs, terrainsurfaces, textures, toon @@ -1014,6 +1014,13 @@ type rotation: float32 scale: float32 # mild per-instance jitter around 1 + PropTextureCache* = ref object + ## Share immutable prop atlases within one GL context. Keep the cache with + ## the packs' asset owner and discard it when that context is destroyed. + ## Entries retain texture handles and digests, never CPU pixel copies. + textures: Table[string, GLuint] + hits, misses: int + PropPack* = ref object models: seq[PropModel] names: OrderedTable[string, int] @@ -1432,9 +1439,39 @@ proc loadTreeTextures(style: TreeStyle): seq[seq[Image]] = c.a = uint8(min(c.a.float32 * alphaScale, 255)) result.add chain -proc buildTextureArray(layers: seq[seq[Image]], wrap: GLint): GLuint = +proc newPropTextureCache*(): PropTextureCache = + ## Creates an optional cache for one context's immutable prop atlases. + PropTextureCache() + +proc propTextureCacheStats*(cache: PropTextureCache): tuple[entries, hits, misses: int] = + ## Reports sharing without changing atlas ownership or lifetime. + if cache.isNil: (0, 0, 0) + else: (cache.textures.len, cache.hits, cache.misses) + +proc propTextureKey(layers: seq[seq[Image]]; wrap: GLint): string = + var state = newSha1State() + state.update($wrap & ":" & $layers.len & ";") + for chain in layers: + state.update($chain.len & ";") + for mip in chain: + state.update($mip.width & ":" & $mip.height & ":" & $mip.data.len & ";") + if mip.data.len > 0: + let bytes = cast[ptr UncheckedArray[char]](mip.data[0].unsafeAddr) + state.update(bytes.toOpenArray(0, mip.data.len * sizeof(mip.data[0]) - 1)) + $SecureHash(state.finalize()) + +proc buildTextureArray(layers: seq[seq[Image]], wrap: GLint; + cache: PropTextureCache = nil): GLuint = ## Uploads equally sized RGBA mip chains as one anisotropic ## GL_TEXTURE_2D_ARRAY, one chain per layer. + var key: string + if not cache.isNil: + key = propTextureKey(layers, wrap) + if key in cache.textures: + inc cache.hits + glBindTexture(GL_TEXTURE_2D_ARRAY, 0) + return cache.textures[key] + inc cache.misses glGenTextures(1, result.addr) glBindTexture(GL_TEXTURE_2D_ARRAY, result) for level, mip in layers[0]: @@ -1466,12 +1503,14 @@ proc buildTextureArray(layers: seq[seq[Image]], wrap: GLint): GLuint = glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, wrap) glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, wrap) glBindTexture(GL_TEXTURE_2D_ARRAY, 0) + if not cache.isNil and result != 0: + cache.textures[key] = result proc loadPropPack*( paths: openArray[string], unitHeight = true, brightness = 1.0'f, only: seq[string] = @[], textured = false, repeatTexture = false, textureSize = 0, mergeNodes = false, materialColors = false, - textureOverride: Image = nil + textureOverride: Image = nil, textureCache: PropTextureCache = nil ): PropPack = ## Loads named glTF props, scaled to unit height unless disabled. ## MergeNodes joins each file into one prop named after its file stem. @@ -1512,7 +1551,7 @@ proc loadPropPack*( GL_REPEAT.GLint else: GL_CLAMP_TO_EDGE.GLint - result.textureArray = buildTextureArray(chains, wrap) + result.textureArray = buildTextureArray(chains, wrap, textureCache) for model in result.models: model.textureArray = result.textureArray if unitHeight: @@ -1526,12 +1565,12 @@ proc loadPropPack*( path: string, unitHeight = true, brightness = 1.0'f, only: seq[string] = @[], textured = false, repeatTexture = false, textureSize = 0, mergeNodes = false, materialColors = false, - textureOverride: Image = nil + textureOverride: Image = nil, textureCache: PropTextureCache = nil ): PropPack = ## Loads an original single-file prop pack through the shared collector. loadPropPack( @[path], unitHeight, brightness, only, textured, repeatTexture, - textureSize, mergeNodes, materialColors, textureOverride) + textureSize, mergeNodes, materialColors, textureOverride, textureCache) proc createPropPack*( nodes: openArray[gltf.Node], textureSize = 512, diff --git a/tests/test_prop_texture_cache.nim b/tests/test_prop_texture_cache.nim new file mode 100644 index 00000000..bdf7b5ae --- /dev/null +++ b/tests/test_prop_texture_cache.nim @@ -0,0 +1,66 @@ +import opengl +var boundTexture = 99.GLuint +proc glBindTexture(target: GLenum; texture: GLuint) = + doAssert target == GL_TEXTURE_2D_ARRAY + boundTexture = texture + +var generated, definitions, uploads: int +proc glGenTextures(count: GLsizei; textures: ptr GLuint) = + doAssert count == 1 + inc generated + textures[] = (100 + generated).GLuint +proc glTexImage3D(target: GLenum; level, internalFormat: GLint; + width, height, depth: GLsizei; border: GLint; format, pixelType: GLenum; + pixels: pointer) = + doAssert target == GL_TEXTURE_2D_ARRAY and internalFormat == GL_RGBA8.GLint + doAssert pixels.isNil + inc definitions +proc glTexSubImage3D(target: GLenum; level, x, y, z: GLint; + width, height, depth: GLsizei; format, pixelType: GLenum; pixels: pointer) = + doAssert target == GL_TEXTURE_2D_ARRAY and not pixels.isNil + inc uploads +proc glTexParameteri(target, name: GLenum; value: GLint) = discard +proc glTexParameterf(target, name: GLenum; value: GLfloat) = discard + +include ../src/polyworld/quadterrain + +let base = newImage(2, 2) +base.fill(rgbx(40, 80, 120, 255)) +let tail = newImage(1, 1) +tail.fill(rgbx(40, 80, 120, 255)) +let layers = @[@[base, tail]] +let key = propTextureKey(layers, GL_REPEAT.GLint) +doAssert key == propTextureKey(@[@[base.copy(), tail.copy()]], GL_REPEAT.GLint) +doAssert key != propTextureKey(layers, GL_CLAMP_TO_EDGE.GLint) +doAssert key != propTextureKey(@[@[base]], GL_REPEAT.GLint) +doAssert key != propTextureKey(@[@[base, tail], @[base, tail]], GL_REPEAT.GLint) +let changed = tail.copy() +changed.data[0].a = 17 +doAssert key != propTextureKey(@[@[base, changed]], GL_REPEAT.GLint), + "A changed coverage mip must not reuse another atlas" +let reshaped = newImage(1, 4) +reshaped.data = base.data +doAssert key != propTextureKey(@[@[reshaped, tail]], GL_REPEAT.GLint) + +let cache = newPropTextureCache() +cache.textures[key] = 73.GLuint +# A cache hit reuses the handle and preserves the uploader's final unbind. +# Stubbed GL calls measure allocations and uploads without a display. +doAssert buildTextureArray(@[@[base.copy(), tail.copy()]], GL_REPEAT.GLint, cache) == 73.GLuint +doAssert boundTexture == 0 +doAssert cache.propTextureCacheStats() == (1, 1, 0) +doAssert newPropTextureCache().propTextureCacheStats() == (0, 0, 0) +let fresh = newPropTextureCache() +let first = buildTextureArray(layers, GL_REPEAT.GLint, fresh) +doAssert (generated, definitions, uploads) == (1, 2, 2) +doAssert buildTextureArray(@[@[base.copy(), tail.copy()]], GL_REPEAT.GLint, fresh) == first +doAssert (generated, definitions, uploads) == (1, 2, 2) +doAssert buildTextureArray(layers, GL_CLAMP_TO_EDGE.GLint, fresh) != first +doAssert (generated, definitions, uploads) == (2, 4, 4) +doAssert fresh.propTextureCacheStats() == (2, 1, 2) +# Uncached callers retain their original allocate-on-every-call behavior. +discard buildTextureArray(layers, GL_REPEAT.GLint) +discard buildTextureArray(layers, GL_REPEAT.GLint) +doAssert generated == 4 + +echo "Prop texture cache preserves pixels, mip shape, wrapping and owner scope" From 329119b8dd95fdaf10558010d0bbea9ebc068de5 Mon Sep 17 00:00:00 2001 From: Richard Higgins Date: Sat, 3 Oct 2026 03:08:59 -0700 Subject: [PATCH 2/2] Compact identical final mip layers in immutable prop packs --- src/polyworld/quadterrain.nim | 22 +++++++++++++++++++++ tests/test_prop_texture_cache.nim | 32 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/polyworld/quadterrain.nim b/src/polyworld/quadterrain.nim index 586375bf..298bdd9b 100644 --- a/src/polyworld/quadterrain.nim +++ b/src/polyworld/quadterrain.nim @@ -1460,6 +1460,27 @@ proc propTextureKey(layers: seq[seq[Image]]; wrap: GLint): string = state.update(bytes.toOpenArray(0, mip.data.len * sizeof(mip.data[0]) - 1)) $SecureHash(state.finalize()) +proc compactPropTextureLayers(layers: var seq[seq[Image]]; models: seq[PropModel]) = + ## Keep first-occurrence layer order; compare final pixels including cutout mips. + if layers.len < 2: return + # Retexturing distinguishes painted white from the reserved unpainted layer. + for model in models: + if model.materialColors: return + var indices: Table[string, int] + var unique: seq[seq[Image]] + var remap: seq[int] + for chain in layers: + let key = propTextureKey(@[chain], 0) + if key notin indices: + indices[key] = unique.len + unique.add chain + remap.add indices[key] + if unique.len == layers.len: return + layers = unique + for model in models: + for index in countup(2, model.uvs.high, 3): + model.uvs[index] = remap[model.uvs[index].int].float32 + proc buildTextureArray(layers: seq[seq[Image]], wrap: GLint; cache: PropTextureCache = nil): GLuint = ## Uploads equally sized RGBA mip chains as one anisotropic @@ -1551,6 +1572,7 @@ proc loadPropPack*( GL_REPEAT.GLint else: GL_CLAMP_TO_EDGE.GLint + compactPropTextureLayers(chains, result.models) result.textureArray = buildTextureArray(chains, wrap, textureCache) for model in result.models: model.textureArray = result.textureArray diff --git a/tests/test_prop_texture_cache.nim b/tests/test_prop_texture_cache.nim index bdf7b5ae..1b5df22c 100644 --- a/tests/test_prop_texture_cache.nim +++ b/tests/test_prop_texture_cache.nim @@ -64,3 +64,35 @@ discard buildTextureArray(layers, GL_REPEAT.GLint) doAssert generated == 4 echo "Prop texture cache preserves pixels, mip shape, wrapping and owner scope" + +# Final mip identity, not base-image identity, owns compact layer sharing. +block: + let original = @[@[base, tail], @[base.copy(), tail.copy()], + @[base.copy(), changed], @[base.copy(), tail.copy()]] + var compact = original + let model = PropModel(uvs: @[0.25'f32, 0.75, 3, 0.5, 0.125, 2, 1, 0, 1]) + + let originalUvs = model.uvs + compactPropTextureLayers(compact, @[model]) + doAssert compact.len == 2 + doAssert model.uvs == @[0.25'f32, 0.75, 0, 0.5, 0.125, 1, 1, 0, 0] + for index in countup(2, model.uvs.high, 3): + for mip in 0 ..< original[originalUvs[index].int].len: + doAssert compact[model.uvs[index].int][mip].data == + original[originalUvs[index].int][mip].data + + let beforeUploads = uploads + discard buildTextureArray(compact, GL_REPEAT.GLint) + doAssert uploads - beforeUploads == 4, "Only two distinct two-mip layers upload" + let stableUvs = model.uvs + compactPropTextureLayers(compact, @[model]) + doAssert model.uvs == stableUvs and compact.len == 2 + +echo "Prop layer compaction preserves all sampled mips and material references" + +block: + var layers = @[@[base, tail], @[base.copy(), tail.copy()]] + let model = PropModel(materialColors: true, uvs: @[0'f32, 0, 1]) + compactPropTextureLayers(layers, @[model]) + doAssert layers.len == 2 and model.uvs[2] == 1, + "Retexturable packs retain painted versus unpainted layer identity"