From f5fa704269ff78597d9a2593e6a46d7c02a1e81c Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 6 Jul 2026 10:32:56 +0200 Subject: [PATCH] perf(runner): draw static world faces via the surface cache Wires the surface-cache primitives (#20) into the world-face draw. A face is CACHEABLE when its lightmap is constant over time -- fullbright (LightOfs<0) or every active lightstyle is style 0 ('m', the only time-independent style) -- AND its texture is not animated (no '+' chain). Such a face is baked ONCE via render.BakeSurface into a per-face render.CachedSurface (keyed by face index, persisted for the map's lifetime -- static faces never change, so no invalidation) and painted with render.FillPerspectiveCachedPolygon: a single fetch+store per pixel instead of a per-pixel lightmap bilinear + colormap lookup. Animated faces (flickering lights, '+' textures) keep the per-pixel lightmapped path; sky/turbulent are unchanged. texMinS/texMinT for the bake come straight from lmInfo.MinS/MinT, since TransformFaceLightmapped defines LmS=(U-MinS)/16 (so U=MinS+LmS*16, and the cache is indexed by LmS*16 / LmT*16). Verified in-browser (Firefox/Playwright, lq_e0m1): the world renders pixel-identical to the per-pixel path (walls fully textured + lit, no artifacts) and the same scene measured 1.32 -> 1.56 fps (+18%) under the identical throttled harness. The win is scene-dependent -- larger in wall-heavy views, bounded here by the ~half-screen of (uncached) water + sky; the isolated cached fill benchmarks 2.45x faster than the lightmapped fill. Co-Authored-By: Claude Opus 4.8 --- runner/render.go | 53 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/runner/render.go b/runner/render.go index 52ba4fd..66c49ec 100644 --- a/runner/render.go +++ b/runner/render.go @@ -173,6 +173,13 @@ func setupRenderer(opts setupRendererOpts) error { // so world rendering doesn't allocate two slices per face per frame. var lmPlaneScratch []byte var lmAccumScratch []int + // Surface cache: STATIC world faces (constant lightmap + non-animated + // texture) bake once into a CachedSurface keyed by face index and reuse + // it across frames, so their span loop becomes a single fetch+store. + // Persists for the map's lifetime (faces never move; the cached lighting + // never changes for static faces, so no invalidation is needed). + faceSurfCache := make(map[int]*render.CachedSurface) + var cachedVertScratch []render.CachedVertex frameCount := 0 prevEntityOrigin := make(map[int][3]float32) loggedWireSpawn := false @@ -575,8 +582,50 @@ func setupRenderer(opts setupRendererOpts) error { if err != nil { continue } - plane := buildLightmapPlane(lmInfo, lightingLump, turbTimeSec, &lmPlaneScratch, &lmAccumScratch) - _ = render.FillPerspectiveLightmappedPolygon(fb, tex, &cm, lverts, plane, lmInfo.Width, lmInfo.Height) + + // A face is cacheable when its lightmap is CONSTANT over time + // (fullbright, or every active lightstyle is style 0 = 'm') + // and its texture is not animated ('+' chains). Those bake + // once and paint via the fast cached fill; animated faces keep + // the per-pixel lightmapped path. + staticLM := lmInfo.LightOfs < 0 + if !staticLM { + staticLM = true + for _, st := range lmInfo.Styles { + if st != 0 && st != 255 { + staticLM = false + break + } + } + } + cacheable := staticLM && !strings.HasPrefix(name, "+") + + drewCached := false + if cacheable { + surf := faceSurfCache[ref.FaceIdx] + if surf == nil { + plane := buildLightmapPlane(lmInfo, lightingLump, turbTimeSec, &lmPlaneScratch, &lmAccumScratch) + surf = &render.CachedSurface{} + if bakeErr := render.BakeSurface(surf, tex, &cm, plane, lmInfo.Width, lmInfo.Height, lmInfo.MinS, lmInfo.MinT); bakeErr != nil { + surf = nil + } else { + faceSurfCache[ref.FaceIdx] = surf + } + } + if surf != nil { + cv := cachedVertScratch[:0] + for _, lv := range lverts { + cv = append(cv, render.CachedVertex{X: lv.X, Y: lv.Y, Z: lv.Z, CU: lv.LmS * 16, CV: lv.LmT * 16}) + } + cachedVertScratch = cv + _ = render.FillPerspectiveCachedPolygon(fb, surf, cv) + drewCached = true + } + } + if !drewCached { + plane := buildLightmapPlane(lmInfo, lightingLump, turbTimeSec, &lmPlaneScratch, &lmAccumScratch) + _ = render.FillPerspectiveLightmappedPolygon(fb, tex, &cm, lverts, plane, lmInfo.Width, lmInfo.Height) + } } }