Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 67 additions & 6 deletions src/polyworld/quadterrain.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -1432,9 +1439,60 @@ 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 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
## 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]:
Expand Down Expand Up @@ -1466,12 +1524,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.
Expand Down Expand Up @@ -1512,7 +1572,8 @@ proc loadPropPack*(
GL_REPEAT.GLint
else:
GL_CLAMP_TO_EDGE.GLint
result.textureArray = buildTextureArray(chains, wrap)
compactPropTextureLayers(chains, result.models)
result.textureArray = buildTextureArray(chains, wrap, textureCache)
for model in result.models:
model.textureArray = result.textureArray
if unitHeight:
Expand All @@ -1526,12 +1587,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,
Expand Down
98 changes: 98 additions & 0 deletions tests/test_prop_texture_cache.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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"

# 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"
Loading