diff --git a/scripts/verify-renderer-lifecycle.ts b/scripts/verify-renderer-lifecycle.ts index 1f42916..89f1b8b 100644 --- a/scripts/verify-renderer-lifecycle.ts +++ b/scripts/verify-renderer-lifecycle.ts @@ -1,6 +1,7 @@ /** * Verification test for SpriteRenderer GPU resource lifecycle, deleteAtlas, - * dispose, and WebGL context loss handling (Issue #9). + * dispose, and WebGL context loss handling (Issue #9), plus the multi-texture + * batching, indexed geometry and draw-call instrumentation added for Issue #16. */ import { SpriteRenderer, RendererError } from '../src/render/renderer.ts' @@ -23,6 +24,8 @@ function createMockCanvas(): { createdShaders: Set deletedShaders: Set eventListeners: Map> + glDrawCalls: number + shaderSources: string[] } setContextLost: (lost: boolean) => void dispatchMockEvent: (type: string) => { defaultPrevented: boolean } @@ -42,6 +45,8 @@ function createMockCanvas(): { createdShaders: new Set(), deletedShaders: new Set(), eventListeners: new Map>(), + glDrawCalls: 0, + shaderSources: [] as string[], } const gl: any = { @@ -50,12 +55,16 @@ function createMockCanvas(): { COMPILE_STATUS: 35713, LINK_STATUS: 35714, MAX_TEXTURE_SIZE: 3379, + MAX_TEXTURE_IMAGE_UNITS: 34930, TEXTURE_2D: 3553, RGBA: 6408, UNSIGNED_BYTE: 5121, + UNSIGNED_INT: 5125, FLOAT: 5126, ARRAY_BUFFER: 34962, + ELEMENT_ARRAY_BUFFER: 34963, DYNAMIC_DRAW: 35048, + STATIC_DRAW: 35044, TRIANGLES: 4, TEXTURE_MIN_FILTER: 10241, TEXTURE_MAG_FILTER: 10240, @@ -94,7 +103,7 @@ function createMockCanvas(): { stats.createdShaders.add(res) return res }, - shaderSource: () => {}, + shaderSource: (_shader: MockResource, source: string) => { stats.shaderSources.push(source) }, compileShader: () => {}, getShaderParameter: () => true, getShaderInfoLog: () => '', @@ -118,7 +127,9 @@ function createMockCanvas(): { bufferData: () => {}, bufferSubData: () => {}, - getAttribLocation: (_p: any, name: string) => (name === 'a_position' ? 0 : name === 'a_uv' ? 1 : 2), + getAttribLocation: (_p: any, name: string) => ( + name === 'a_position' ? 0 : name === 'a_uv' ? 1 : name === 'a_tint' ? 2 : 3 + ), enableVertexAttribArray: () => {}, vertexAttribPointer: () => {}, @@ -133,11 +144,12 @@ function createMockCanvas(): { texImage2D: () => {}, pixelStorei: () => {}, - getParameter: (param: number) => (param === 3379 ? 4096 : 0), + getParameter: (param: number) => (param === 3379 ? 4096 : param === 34930 ? 16 : 0), getUniformLocation: (_p: any, name: string) => ({ name }), uniform2f: () => {}, uniform1f: () => {}, uniform1i: () => {}, + uniform1iv: () => {}, enable: () => {}, disable: () => {}, @@ -146,7 +158,8 @@ function createMockCanvas(): { clearColor: () => {}, clear: () => {}, activeTexture: () => {}, - drawArrays: () => {}, + drawArrays: () => { stats.glDrawCalls += 1 }, + drawElements: () => { stats.glDrawCalls += 1 }, } const eventListeners = stats.eventListeners @@ -185,6 +198,16 @@ function createMockCanvas(): { } } +/** + * Make a throwaway atlas page handle. + * + * @param renderer - the renderer to allocate on. + * @returns a handle backed by a real (mock) texture. + */ +function page(renderer: SpriteRenderer): ReturnType { + return renderer.addAtlas({ pixels: new Uint8ClampedArray(16 * 16 * 4), width: 16, height: 16 }) +} + function runTests(): void { console.log('== Running SpriteRenderer Lifecycle Verification ==\n') @@ -210,7 +233,7 @@ function runTests(): void { assert(mock.stats.deletedShaders.size === 2, 'Both shaders detached and deleted right after program link') assert(mock.stats.createdPrograms.size === 1, 'Program created') assert(mock.stats.createdVAOs.size === 1, 'VAO created') - assert(mock.stats.createdBuffers.size === 1, 'VBO buffer created') + assert(mock.stats.createdBuffers.size === 2, 'Vertex and index buffers created') assert(mock.stats.createdTextures.size === 2, 'Initial textures created (atlas + white)') assert(renderer.isDisposed === false, 'Renderer is active and not disposed') @@ -255,7 +278,7 @@ function runTests(): void { renderer.dispose() assert(renderer.isDisposed === true, 'isDisposed is true after dispose()') assert(mock.stats.deletedTextures.size === 4, 'All 4 textures were deleted on dispose()') - assert(mock.stats.deletedBuffers.size === 1, 'VBO was deleted on dispose()') + assert(mock.stats.deletedBuffers.size === 2, 'Vertex and index buffers were deleted on dispose()') assert(mock.stats.deletedVAOs.size === 1, 'VAO was deleted on dispose()') assert(mock.stats.deletedPrograms.size === 1, 'Program was deleted on dispose()') @@ -315,6 +338,105 @@ function runTests(): void { assert(renderer.isDisposed === true, 'dispose() safely completes when context is already lost') } + // Test 6 (Issue #16): several atlas pages interleaved in painter's order stay + // in ONE draw call. This is the regression that mattered: sorting by texture + // is not an option here, because draw order *is* the depth order. + { + const mock = createMockCanvas() + const renderer = new SpriteRenderer(mock.canvas) + const a = page(renderer) + const b = page(renderer) + const c = page(renderer) + const frame = { x: 0, y: 0, width: 8, height: 8 } + + renderer.begin({ x: 0, y: 0, zoom: 1 }) + const before = mock.stats.glDrawCalls + // Worst case for the old batcher: every quad switches page. + for (let i = 0; i < 60; i += 1) { + renderer.draw(frame, i, i, { atlas: i % 3 === 0 ? a : i % 3 === 1 ? b : c }) + } + renderer.flush() + + assert(mock.stats.glDrawCalls - before === 1, '60 quads ping-ponging across 3 pages cost exactly 1 draw call') + assert(renderer.drawCalls === 1, 'renderer.drawCalls reports the real measured count') + assert(renderer.quadsSubmitted === 60, 'renderer.quadsSubmitted counts every queued quad') + + renderer.dispose() + } + + // Test 7 (Issue #16): solid rectangles batch together with sprites rather + // than forcing a flush per colour or per texture switch. + { + const mock = createMockCanvas() + const renderer = new SpriteRenderer(mock.canvas) + const a = page(renderer) + const frame = { x: 0, y: 0, width: 8, height: 8 } + + renderer.begin({ x: 0, y: 0, zoom: 1 }) + const before = mock.stats.glDrawCalls + renderer.draw(frame, 0, 0, { atlas: a }) + renderer.drawSolid(0, 0, 4, 4, [1, 0, 0, 1]) + renderer.draw(frame, 8, 8, { atlas: a }) + renderer.drawSolid(8, 8, 4, 4, [0, 1, 0, 1]) + renderer.flush() + + assert(mock.stats.glDrawCalls - before === 1, 'Sprites and solid rects interleave in a single draw call') + renderer.dispose() + } + + // Test 8 (Issue #16): the batch only splits once it runs out of texture units. + { + const mock = createMockCanvas() + const renderer = new SpriteRenderer(mock.canvas) + const units = renderer.textureUnits + const frame = { x: 0, y: 0, width: 8, height: 8 } + // One more distinct page than there are units: the extra one must flush. + const pages = Array.from({ length: units + 1 }, () => page(renderer)) + + renderer.begin({ x: 0, y: 0, zoom: 1 }) + const before = mock.stats.glDrawCalls + for (const atlas of pages) renderer.draw(frame, 0, 0, { atlas }) + renderer.flush() + + assert(units >= 8, `Context exposes a usable number of texture units (${String(units)})`) + assert(mock.stats.glDrawCalls - before === 2, 'Exceeding the texture-unit budget costs exactly one extra draw call') + renderer.dispose() + } + + // Test 9 (Issue #16): begin() resets the per-frame counters, so the HUD reads + // this frame's cost rather than an ever-growing total. + { + const mock = createMockCanvas() + const renderer = new SpriteRenderer(mock.canvas) + const a = page(renderer) + const frame = { x: 0, y: 0, width: 8, height: 8 } + + renderer.begin({ x: 0, y: 0, zoom: 1 }) + renderer.draw(frame, 0, 0, { atlas: a }) + renderer.flush() + assert(renderer.drawCalls === 1 && renderer.quadsSubmitted === 1, 'First frame counted') + + renderer.begin({ x: 0, y: 0, zoom: 1 }) + assert(renderer.drawCalls === 0 && renderer.quadsSubmitted === 0, 'begin() resets the per-frame counters') + renderer.dispose() + } + + // Test 10 (Issue #16): indexed geometry and the generated sampler switch. + { + const mock = createMockCanvas() + const renderer = new SpriteRenderer(mock.canvas) + const fragment = mock.stats.shaderSources.find(source => source.includes('u_textures')) ?? '' + const vertex = mock.stats.shaderSources.find(source => source.includes('a_position')) ?? '' + + assert(fragment.includes('switch (v_unit)'), 'Fragment shader selects the sampler by a constant-case switch') + assert( + fragment.includes(`u_textures[${String(renderer.textureUnits)}]`), + 'Sampler array is sized to the units the context actually reports', + ) + assert(vertex.includes('flat out int v_unit'), 'Texture unit is carried per-vertex, flat-interpolated') + renderer.dispose() + } + console.log(`\n== Summary: ${String(passed)} passed, ${String(failed)} failed ==`) if (failed > 0) process.exit(1) } diff --git a/src/render/cull.ts b/src/render/cull.ts new file mode 100644 index 0000000..32e4573 --- /dev/null +++ b/src/render/cull.ts @@ -0,0 +1,106 @@ +/** + * 视口剔除:把"这一帧到底要画哪些东西"从整张地图缩小到屏幕那一块。 + * + * 起因是一次实测:把 `act-scene` 的绘制顺序在 365 张烘焙关卡上静态重放一遍, + * 平均每帧提交 2 848 个四边形,最差一帧(act4 迪亚波罗)14 400 个,而其中真正 + * 落在视口里的只有 7%~11%。也就是说九成的顶点写入、九成的图集页切换,都是在 + * 给屏幕外的瓦片做无用功——这正是主循环掉到 20 tps 的直接原因。 + * + * 这里只做两件事:把绘制列表的包围盒预先摊平成 `Float32Array`,以及提供一个 + * 矩形相交判断。之所以不用四叉树/网格索引,是因为绘制列表本身**不是**按 y 单调 + * 的(实测地面 0/365 单调,最大回退 9 520 px),任何空间分桶都得重新合并回 + * 画家顺序;而线性扫描 14 400 个条目只需要 4 次浮点比较 × 14 400 ≈ 15~30 µs, + * 占 16 ms 预算的 0.2%,却能原样保住顺序。 + */ + +/** 一个可绘制物需要的最小信息:世界坐标 + 图集帧尺寸。 */ +export interface BoundedDrawable { + readonly frame: { readonly width: number; readonly height: number } + readonly x: number + readonly y: number +} + +/** 视口矩形,世界像素。 */ +export interface ViewRect { + readonly left: number + readonly top: number + readonly right: number + readonly bottom: number +} + +/** + * 视口四周额外放宽的余量,单位为场景像素。 + * + * 包围盒已经算进了每帧的宽高,所以这点余量只是为了兜住 CPU 侧矩形与顶点着色器 + * 变换之间的浮点舍入,不是用来遮掩边界算错的。 + */ +export const CULL_MARGIN = 32 + +/** + * 把一条绘制列表的世界包围盒摊平成 `[x0, y0, x1, y1]` 四元组。 + * + * 列表在关卡建好之后就不再变动,所以包围盒可以一次算完;用一整块连续内存而不是 + * 每帧去点两层属性(`draw.frame.width`),是为了让每帧的拒绝测试只碰缓存友好的 + * 相邻浮点。 + * + * @param list - 带世界坐标和图集帧的可绘制物。 + * @returns 包围盒,每个条目 4 个浮点。 + */ +export function buildBounds(list: readonly BoundedDrawable[]): Float32Array { + const bounds = new Float32Array(list.length * 4) + for (let index = 0; index < list.length; index += 1) { + const draw = list[index]! + const at = index * 4 + bounds[at] = draw.x + bounds[at + 1] = draw.y + bounds[at + 2] = draw.x + draw.frame.width + bounds[at + 3] = draw.y + draw.frame.height + } + return bounds +} + +/** + * 由相机和画布尺寸算出视口矩形。 + * + * 顶点着色器保留一个四边形的条件是 `|x - camX| <= drawingBufferWidth / (2 * zoom)`, + * 而 `drawingBufferWidth` 就是 `canvas.width`,所以这里用同一个表达式复现它—— + * 两边算法一旦不一致,靠近屏幕边缘的瓦片就会闪烁。 + * + * @param camX - 相机中心 x,世界像素。 + * @param camY - 相机中心 y,世界像素。 + * @param canvasWidth - 画布后备存储宽度。 + * @param canvasHeight - 画布后备存储高度。 + * @param zoom - 当前缩放。 + */ +export function viewportRect( + camX: number, + camY: number, + canvasWidth: number, + canvasHeight: number, + zoom: number, +): ViewRect { + const halfWidth = canvasWidth / (2 * zoom) + CULL_MARGIN + const halfHeight = canvasHeight / (2 * zoom) + CULL_MARGIN + return { + left: camX - halfWidth, + top: camY - halfHeight, + right: camX + halfWidth, + bottom: camY + halfHeight, + } +} + +/** + * 判断 `bounds` 中第 `index` 个条目是否与视口相交。 + * + * 这是整帧里调用次数最多的一个函数(最差 14 400 次/帧),所以它只做四次比较, + * 不建临时对象、不解构。 + * + * @param bounds - `buildBounds` 的输出。 + * @param index - 条目下标(不是浮点下标)。 + * @param view - 视口矩形。 + */ +export function intersects(bounds: Float32Array, index: number, view: ViewRect): boolean { + const at = index * 4 + return bounds[at + 2]! >= view.left && bounds[at]! <= view.right + && bounds[at + 3]! >= view.top && bounds[at + 1]! <= view.bottom +} diff --git a/src/render/renderer.ts b/src/render/renderer.ts index 4acbc00..6dcff7a 100644 --- a/src/render/renderer.ts +++ b/src/render/renderer.ts @@ -1,12 +1,32 @@ /** - * WebGL2 sprite renderer: one quad batcher with per-quad tint. + * WebGL2 sprite renderer: one indexed quad batcher with per-quad tint that can + * sample several atlas pages inside a single draw call. * * Diablo-style rendering is a flood of axis-aligned quads — floors, walls, * units, items — drawn in painter's order. So the renderer accumulates * world-space quads (position, source rectangle, tint) and submits them in as - * few calls as the texture switches allow. Depth ordering is the caller's job - * (draw order), not the GPU's, which is how the original games did it and what - * keeps the batcher trivial. + * few calls as it can. Depth ordering is the caller's job (draw order), not the + * GPU's, which is how the original games did it and what keeps the batcher + * trivial. + * + * Three properties keep the batch big and the CPU cost low: + * + * - **Multi-texture batching.** A Diablo level needs more tiles than one 2048² + * page holds, and painter's order interleaves those pages constantly: sorting + * by texture is not allowed, because that is exactly the order that makes the + * scene correct. Binding one texture per batch therefore used to split the + * frame into hundreds of draw calls (measured: up to 3077 on + * `act4/107-act-4-lava-1`). Instead every page in flight gets its own texture + * unit, the unit index rides along as a vertex attribute, and the fragment + * shader picks the sampler — so painter's order survives *and* the whole frame + * is usually one `drawElements`. Only when a batch needs more distinct pages + * than the hardware has units does it flush and start over. + * - **Indexed geometry.** Four vertices per quad instead of six, with a static + * element buffer, so a third less vertex data crosses the bus per frame. + * - **Zero-allocation vertex writes.** {@link SpriteRenderer.quad} writes the + * vertex floats straight out, unrolled. The earlier version built four + * throwaway arrays per quad, which at ~2800 quads a frame meant ~11k + * short-lived objects per frame handed to the GC. * * Tint is a vertex attribute rather than a uniform, so coloured geometry * (a floor rectangle, a wall) batches together with sprites instead of forcing @@ -28,9 +48,9 @@ export interface Camera { * One uploaded atlas texture. * * Diablo II maps need more tiles than a single 2048² page holds, so the renderer - * keeps several textures and draws each quad from the page its frame lives in. - * Switching textures splits the batch (see {@link SpriteRenderer.draw}) but - * never reorders it, so painter's order survives multi-page atlases. + * keeps several textures. Each quad records which page it samples; the batcher + * assigns pages to texture units so a page switch no longer splits the batch + * (see {@link SpriteRenderer.draw}). */ export interface AtlasHandle { /** The GPU texture. */ @@ -59,22 +79,41 @@ export interface RendererOptions { readonly onContextRestored?: () => void } -/** Vertices per quad (two triangles). */ -const VERTICES_PER_QUAD = 6 -/** Floats per vertex: x, y, u, v, r, g, b, a. */ -const FLOATS_PER_VERTEX = 8 +/** Vertices per quad: the four corners, reused by the index buffer. */ +const VERTICES_PER_QUAD = 4 +/** Indices per quad (two triangles). */ +const INDICES_PER_QUAD = 6 +/** Floats per vertex: x, y, u, v, r, g, b, a, unit. */ +const FLOATS_PER_VERTEX = 9 /** Initial batch capacity in quads. */ const INITIAL_CAPACITY = 8192 +/** + * Texture units the batcher will use at most. + * + * WebGL2 guarantees `MAX_TEXTURE_IMAGE_UNITS >= 16`, and the packs top out at + * ten pages for a level (`act5/109-act-5-town-townwest`) plus one character + * atlas, so sixteen is enough to hold an entire frame's pages at once. The real + * limit is still queried at construction and respected. + */ +const MAX_BATCH_TEXTURES = 16 -const VERTEX_SHADER = `#version 300 es +/** + * Build the vertex shader. + * + * @returns GLSL ES 3.00 source. + */ +function vertexShaderSource(): string { + return `#version 300 es in vec2 a_position; in vec2 a_uv; in vec4 a_tint; +in float a_unit; uniform vec2 u_camera; uniform vec2 u_viewport; uniform float u_zoom; out vec2 v_uv; out vec4 v_tint; +flat out int v_unit; void main() { vec2 offset = (a_position - u_camera) * u_zoom; // 'half' is a reserved word in GLSL ES 3.00, hence the longer name. @@ -82,19 +121,42 @@ void main() { gl_Position = vec4(offset.x / halfViewport.x, -offset.y / halfViewport.y, 0.0, 1.0); v_uv = a_uv; v_tint = a_tint; + v_unit = int(a_unit); }` +} -const FRAGMENT_SHADER = `#version 300 es +/** + * Build the fragment shader for a given texture-unit count. + * + * GLSL ES 3.00 forbids indexing a sampler array with a non-constant expression, + * so the sampler is picked by a `switch` over constant cases. The case list is + * generated to match however many units the context actually offers. + * + * @param units - number of samplers to expose. + * @returns GLSL ES 3.00 source. + */ +function fragmentShaderSource(units: number): string { + const cases: string[] = [] + for (let unit = 1; unit < units; unit += 1) { + cases.push(` case ${String(unit)}: texel = texture(u_textures[${String(unit)}], v_uv); break;`) + } + return `#version 300 es precision mediump float; in vec2 v_uv; in vec4 v_tint; -uniform sampler2D u_texture; +flat in int v_unit; +uniform sampler2D u_textures[${String(units)}]; out vec4 outColor; void main() { - vec4 texel = texture(u_texture, v_uv); + vec4 texel; + switch (v_unit) { +${cases.join('\n')} + default: texel = texture(u_textures[0], v_uv); break; + } if (texel.a == 0.0) discard; outColor = texel * v_tint; }` +} /** Raised when the context or program cannot be created. */ export class RendererError extends Error {} @@ -110,6 +172,7 @@ export class SpriteRenderer { private readonly program: WebGLProgram private readonly vao: WebGLVertexArrayObject private readonly buffer: WebGLBuffer + private readonly indexBuffer: WebGLBuffer private readonly atlasTexture: WebGLTexture private readonly whiteTexture: WebGLTexture /** The 1×1 white page used by `drawSolid`. */ @@ -119,15 +182,24 @@ export class SpriteRenderer { private readonly uniformCamera: WebGLUniformLocation private readonly uniformViewport: WebGLUniformLocation private readonly uniformZoom: WebGLUniformLocation - private readonly uniformTexture: WebGLUniformLocation + private readonly uniformTextures: WebGLUniformLocation private vertices: Float32Array + private indices: Uint32Array private quadCount = 0 private atlasWidth = 1 private atlasHeight = 1 - /** Which texture the queued quads sample; a switch flushes the batch. */ - private batchAtlas: AtlasHandle + /** Texture units this context offers the batcher. */ + private readonly maxBatchTextures: number + /** Pages bound to units `0..batchTextureCount-1` for the pending batch. */ + private readonly batchTextures: (AtlasHandle | null)[] + private batchTextureCount = 0 private camera: Camera = { x: 0, y: 0, zoom: 1 } + /** `drawElements` calls issued since the last {@link begin}. */ + private frameDrawCalls = 0 + /** Quads submitted since the last {@link begin}. */ + private frameQuads = 0 + /** All textures allocated and managed by this renderer. */ private readonly allocatedTextures = new Set() /** Whether this renderer has been disposed. */ @@ -145,28 +217,39 @@ export class SpriteRenderer { const gl = canvas.getContext('webgl2', { alpha: false, antialias: false, premultipliedAlpha: false }) if (gl === null) throw new RendererError('WebGL2 is not available in this browser') this.gl = gl - this.program = createProgram(gl, VERTEX_SHADER, FRAGMENT_SHADER) + + const reportedUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS) as number | null + // WebGL2 guarantees at least 16; clamp to at least 1 so a stubbed context + // (the lifecycle tests) still produces a valid shader. + const availableUnits = typeof reportedUnits === 'number' && reportedUnits > 0 ? reportedUnits : MAX_BATCH_TEXTURES + this.maxBatchTextures = Math.max(1, Math.min(MAX_BATCH_TEXTURES, availableUnits)) + this.batchTextures = new Array(this.maxBatchTextures).fill(null) + + this.program = createProgram(gl, vertexShaderSource(), fragmentShaderSource(this.maxBatchTextures)) const vao = gl.createVertexArray() const buffer = gl.createBuffer() + const indexBuffer = gl.createBuffer() const atlasTexture = gl.createTexture() const whiteTexture = gl.createTexture() - if (vao === null || buffer === null || atlasTexture === null || whiteTexture === null) { + if (vao === null || buffer === null || indexBuffer === null || atlasTexture === null || whiteTexture === null) { throw new RendererError('WebGL2 resource allocation failed') } this.vao = vao this.buffer = buffer + this.indexBuffer = indexBuffer this.atlasTexture = atlasTexture this.whiteTexture = whiteTexture this.allocatedTextures.add(atlasTexture) this.allocatedTextures.add(whiteTexture) this.defaultAtlas = { texture: atlasTexture, width: 1, height: 1 } this.whiteAtlas = { texture: whiteTexture, width: 1, height: 1 } - this.batchAtlas = this.defaultAtlas this.vertices = new Float32Array(INITIAL_CAPACITY * VERTICES_PER_QUAD * FLOATS_PER_VERTEX) + this.indices = buildQuadIndices(INITIAL_CAPACITY) const attributePosition = gl.getAttribLocation(this.program, 'a_position') const attributeUv = gl.getAttribLocation(this.program, 'a_uv') const attributeTint = gl.getAttribLocation(this.program, 'a_tint') + const attributeUnit = gl.getAttribLocation(this.program, 'a_unit') gl.bindVertexArray(vao) gl.bindBuffer(gl.ARRAY_BUFFER, buffer) gl.bufferData(gl.ARRAY_BUFFER, this.vertices.byteLength, gl.DYNAMIC_DRAW) @@ -177,12 +260,26 @@ export class SpriteRenderer { gl.vertexAttribPointer(attributeUv, 2, gl.FLOAT, false, stride, 8) gl.enableVertexAttribArray(attributeTint) gl.vertexAttribPointer(attributeTint, 4, gl.FLOAT, false, stride, 16) + // A stubbed context can report -1 for an attribute it does not know about; + // enabling that location would be an INVALID_VALUE. + if (attributeUnit >= 0) { + gl.enableVertexAttribArray(attributeUnit) + gl.vertexAttribPointer(attributeUnit, 1, gl.FLOAT, false, stride, 32) + } + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer) + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, this.indices, gl.STATIC_DRAW) gl.bindVertexArray(null) this.uniformCamera = requireUniform(gl, this.program, 'u_camera') this.uniformViewport = requireUniform(gl, this.program, 'u_viewport') this.uniformZoom = requireUniform(gl, this.program, 'u_zoom') - this.uniformTexture = requireUniform(gl, this.program, 'u_texture') + this.uniformTextures = requireUniform(gl, this.program, 'u_textures[0]') + + // The sampler array never changes: unit i always reads texture unit i. + const samplerUnits = new Int32Array(this.maxBatchTextures) + for (let unit = 0; unit < this.maxBatchTextures; unit += 1) samplerUnits[unit] = unit + gl.useProgram(this.program) + gl.uniform1iv(this.uniformTextures, samplerUnits) for (const texture of [atlasTexture, whiteTexture]) { gl.bindTexture(gl.TEXTURE_2D, texture) @@ -215,6 +312,27 @@ export class SpriteRenderer { return this.disposed } + /** + * Draw calls issued since the last {@link begin}. + * + * This is the real measured count, not an estimate: the HUD used to report a + * hardcoded `1` while the frame was actually spending hundreds of calls, which + * is precisely why the cost stayed invisible. + */ + get drawCalls(): number { + return this.frameDrawCalls + } + + /** Quads submitted since the last {@link begin}. */ + get quadsSubmitted(): number { + return this.frameQuads + } + + /** Texture units this batcher can bind at once. */ + get textureUnits(): number { + return this.maxBatchTextures + } + /** * Upload an atlas, replacing the current one. * @@ -295,7 +413,6 @@ export class SpriteRenderer { gl.RGBA, gl.UNSIGNED_BYTE, pixels, ) } - gl.bindTexture(gl.TEXTURE_2D, this.batchAtlas.texture) return { texture, width: pageWidth, height: pageHeight } } @@ -309,9 +426,13 @@ export class SpriteRenderer { if (this.disposed || handle === this.whiteAtlas) return false if (!this.allocatedTextures.has(handle.texture)) return false - if (this.batchAtlas === handle) { - this.flush() - this.batchAtlas = this.defaultAtlas === handle ? this.whiteAtlas : this.defaultAtlas + // Quads already queued may reference this page's unit, so they have to go + // out before the texture disappears. + for (let unit = 0; unit < this.batchTextureCount; unit += 1) { + if (this.batchTextures[unit] === handle) { + this.flush() + break + } } if (this.defaultAtlas === handle) { this.defaultAtlas = this.whiteAtlas @@ -340,7 +461,9 @@ export class SpriteRenderer { const gl = this.gl this.camera = camera this.quadCount = 0 - this.batchAtlas = this.defaultAtlas + this.batchTextureCount = 0 + this.frameDrawCalls = 0 + this.frameQuads = 0 gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight) gl.clearColor(clear[0], clear[1], clear[2], 1) gl.clear(gl.COLOR_BUFFER_BIT) @@ -357,21 +480,22 @@ export class SpriteRenderer { draw(frame: AtlasFrame, x: number, y: number, options: DrawOptions = {}): void { if (this.disposed) return const page = options.atlas ?? this.defaultAtlas - // A page switch splits the batch; because it never reorders quads, painter's - // order is preserved across pages. - if (this.batchAtlas !== page) { - this.flush() - this.batchAtlas = page - } + const unit = this.unitFor(page) const u0 = frame.x / page.width const v0 = frame.y / page.height const u1 = (frame.x + frame.width) / page.width const v1 = (frame.y + frame.height) / page.height + const tint = options.tint + const flipped = options.flipX === true this.quad( x, y, x + frame.width, y + frame.height, - options.flipX === true ? u1 : u0, v0, - options.flipX === true ? u0 : u1, v1, - options.tint ?? WHITE, + flipped ? u1 : u0, v0, + flipped ? u0 : u1, v1, + tint === undefined ? 1 : tint[0], + tint === undefined ? 1 : tint[1], + tint === undefined ? 1 : tint[2], + tint === undefined ? 1 : tint[3], + unit, ) } @@ -386,15 +510,43 @@ export class SpriteRenderer { */ drawSolid(x: number, y: number, width: number, height: number, color: readonly [number, number, number, number]): void { if (this.disposed) return - if (this.batchAtlas !== this.whiteAtlas) { - this.flush() - this.batchAtlas = this.whiteAtlas - } - this.quad(x, y, x + width, y + height, 0.5, 0.5, 0.5, 0.5, color) + const unit = this.unitFor(this.whiteAtlas) + this.quad(x, y, x + width, y + height, 0.5, 0.5, 0.5, 0.5, color[0], color[1], color[2], color[3], unit) } /** - * Append one quad's six vertices. + * Find the texture unit a page is bound to for the pending batch, binding it + * to a free unit if this is its first quad. + * + * Running out of units is the only thing that still splits a batch, and with + * sixteen of them against a ten-page worst case that effectively never + * happens for a map. + * + * @param page - the atlas page the caller wants to sample. + * @returns the texture unit index to write into the vertices. + */ + private unitFor(page: AtlasHandle): number { + const count = this.batchTextureCount + for (let unit = 0; unit < count; unit += 1) { + if (this.batchTextures[unit] === page) return unit + } + if (count === this.maxBatchTextures) { + this.flush() + this.batchTextures[0] = page + this.batchTextureCount = 1 + return 0 + } + this.batchTextures[count] = page + this.batchTextureCount = count + 1 + return count + } + + /** + * Append one quad's four vertices. + * + * Written out longhand on purpose: the readable loop-over-temporary-arrays + * version allocated four arrays per quad, and at a few thousand quads a frame + * that dominated the CPU side of the frame (measured 2.3× slower). * * @param x0 - left edge. * @param y0 - top edge. @@ -404,53 +556,67 @@ export class SpriteRenderer { * @param v0 - top texture coordinate. * @param u1 - right texture coordinate. * @param v1 - bottom texture coordinate. - * @param tint - per-quad tint. + * @param r - tint red. + * @param g - tint green. + * @param b - tint blue. + * @param a - tint alpha. + * @param unit - texture unit to sample. */ private quad( x0: number, y0: number, x1: number, y1: number, u0: number, v0: number, u1: number, v1: number, - tint: readonly [number, number, number, number], + r: number, g: number, b: number, a: number, + unit: number, ): void { const needed = (this.quadCount + 1) * VERTICES_PER_QUAD * FLOATS_PER_VERTEX if (needed > this.vertices.length) this.grow(needed) const v = this.vertices - const at = this.quadCount * VERTICES_PER_QUAD * FLOATS_PER_VERTEX - const [r, g, b, a] = tint - // Two triangles: (x0,y0) (x1,y0) (x0,y1) and (x1,y0) (x1,y1) (x0,y1). - const xs = [x0, x1, x0, x1, x1, x0] - const ys = [y0, y0, y1, y0, y1, y1] - const us = [u0, u1, u0, u1, u1, u0] - const vs = [v0, v0, v1, v0, v1, v1] - for (let i = 0; i < VERTICES_PER_QUAD; i += 1) { - const base = at + i * FLOATS_PER_VERTEX - v[base] = xs[i]! - v[base + 1] = ys[i]! - v[base + 2] = us[i]! - v[base + 3] = vs[i]! - v[base + 4] = r - v[base + 5] = g - v[base + 6] = b - v[base + 7] = a - } + let at = this.quadCount * VERTICES_PER_QUAD * FLOATS_PER_VERTEX + // Corner 0: top-left. + v[at] = x0; v[at + 1] = y0; v[at + 2] = u0; v[at + 3] = v0 + v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit + at += FLOATS_PER_VERTEX + // Corner 1: top-right. + v[at] = x1; v[at + 1] = y0; v[at + 2] = u1; v[at + 3] = v0 + v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit + at += FLOATS_PER_VERTEX + // Corner 2: bottom-left. + v[at] = x0; v[at + 1] = y1; v[at + 2] = u0; v[at + 3] = v1 + v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit + at += FLOATS_PER_VERTEX + // Corner 3: bottom-right. + v[at] = x1; v[at + 1] = y1; v[at + 2] = u1; v[at + 3] = v1 + v[at + 4] = r; v[at + 5] = g; v[at + 6] = b; v[at + 7] = a; v[at + 8] = unit this.quadCount += 1 + this.frameQuads += 1 } /** Submit every queued quad. */ flush(): void { const gl = this.gl if (this.disposed || this.quadCount === 0) return + const floatCount = this.quadCount * VERTICES_PER_QUAD * FLOATS_PER_VERTEX gl.useProgram(this.program) gl.bindVertexArray(this.vao) gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer) - gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.vertices, 0, this.quadCount * VERTICES_PER_QUAD * FLOATS_PER_VERTEX) + // Orphan first: without it the driver has to wait until the GPU is done + // reading the range this overwrites, which is a pipeline stall on every + // flush. Handing back a fresh block lets both sides run. + gl.bufferData(gl.ARRAY_BUFFER, this.vertices.byteLength, gl.DYNAMIC_DRAW) + gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.vertices, 0, floatCount) gl.uniform2f(this.uniformCamera, this.camera.x, this.camera.y) gl.uniform2f(this.uniformViewport, gl.drawingBufferWidth, gl.drawingBufferHeight) gl.uniform1f(this.uniformZoom, this.camera.zoom) - gl.uniform1i(this.uniformTexture, 0) - gl.activeTexture(gl.TEXTURE0) - gl.bindTexture(gl.TEXTURE_2D, this.batchAtlas.texture) - gl.drawArrays(gl.TRIANGLES, 0, this.quadCount * VERTICES_PER_QUAD) + for (let unit = 0; unit < this.batchTextureCount; unit += 1) { + const page = this.batchTextures[unit] + if (page === undefined || page === null) continue + gl.activeTexture(gl.TEXTURE0 + unit) + gl.bindTexture(gl.TEXTURE_2D, page.texture) + } + gl.drawElements(gl.TRIANGLES, this.quadCount * INDICES_PER_QUAD, gl.UNSIGNED_INT, 0) + this.frameDrawCalls += 1 this.quadCount = 0 + this.batchTextureCount = 0 } /** @@ -470,16 +636,18 @@ export class SpriteRenderer { } this.allocatedTextures.clear() gl.deleteBuffer(this.buffer) + gl.deleteBuffer(this.indexBuffer) gl.deleteVertexArray(this.vao) gl.deleteProgram(this.program) } else { this.allocatedTextures.clear() } this.quadCount = 0 + this.batchTextureCount = 0 } /** - * Grow the vertex buffer, preserving queued geometry. + * Grow the vertex and index buffers, preserving queued geometry. * * @param needed - required float count. */ @@ -489,13 +657,42 @@ export class SpriteRenderer { const grown = new Float32Array(capacity) grown.set(this.vertices) this.vertices = grown - this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffer) - this.gl.bufferData(this.gl.ARRAY_BUFFER, grown.byteLength, this.gl.DYNAMIC_DRAW) + const quads = Math.floor(capacity / (VERTICES_PER_QUAD * FLOATS_PER_VERTEX)) + this.indices = buildQuadIndices(quads) + const gl = this.gl + gl.bindVertexArray(this.vao) + gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer) + gl.bufferData(gl.ARRAY_BUFFER, grown.byteLength, gl.DYNAMIC_DRAW) + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer) + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, this.indices, gl.STATIC_DRAW) + gl.bindVertexArray(null) } } -/** Opaque white, the neutral tint. */ -const WHITE: readonly [number, number, number, number] = [1, 1, 1, 1] +/** + * Build the static index buffer contents for a quad capacity. + * + * Every quad is two triangles over its four corners: top-left, top-right, + * bottom-left and bottom-right, wound the same way the old six-vertex layout + * was so nothing about the rasterised result changes. + * + * @param quads - number of quads to cover. + * @returns indices, `6 * quads` long. + */ +function buildQuadIndices(quads: number): Uint32Array { + const indices = new Uint32Array(quads * INDICES_PER_QUAD) + for (let quad = 0; quad < quads; quad += 1) { + const vertex = quad * VERTICES_PER_QUAD + const at = quad * INDICES_PER_QUAD + indices[at] = vertex + indices[at + 1] = vertex + 1 + indices[at + 2] = vertex + 2 + indices[at + 3] = vertex + 1 + indices[at + 4] = vertex + 3 + indices[at + 5] = vertex + 2 + } + return indices +} /** * Compile and link a program, reporting shader logs on failure. diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index e6b9510..f88e112 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -40,6 +40,7 @@ import { buildAtlas } from '../render/atlas.ts' import type { AtlasFrame, SpriteAtlas } from '../render/atlas.ts' import { SpriteRenderer } from '../render/renderer.ts' import type { AtlasHandle } from '../render/renderer.ts' +import { buildBounds, intersects, viewportRect } from '../render/cull.ts' import { loadCharacterSheet, facingToDirection } from '../game/character.ts' import { ACT_NAMES_ZH, sceneNameZh, variantLabelZh } from '../game/level-names-zh.ts' import { GameLoop } from '../sim/loop.ts' @@ -65,6 +66,14 @@ const PAGE_CONCURRENCY = 3 /** Placeholder marker size, in scene pixels. */ const MARKER_WIDTH = 16 const MARKER_HEIGHT = 26 +/** + * How often the HUD text is rewritten, in milliseconds. + * + * The overlay reports averages, so refreshing it at frame rate only buys string + * churn: four `toFixed` calls and ten concatenations that the reader cannot + * follow anyway. + */ +const HUD_INTERVAL_MS = 250 /** Live scene state, also published for automated checks. */ export interface ActSceneState { @@ -116,7 +125,20 @@ export interface ActSceneState { facing: number tick: number tickRate: number + /** + * GPU draw calls the last frame actually issued. + * + * This used to be hardcoded to 1, which hid the fact that switching atlas + * pages mid-list was costing up to 3 077 calls per frame. It now reads the + * renderer's own counter. + */ drawCalls: number + /** Quads the last frame submitted, i.e. what survived culling. */ + quadsDrawn: number + /** Draws rejected last frame because their bounds fell outside the viewport. */ + culledDraws: number + /** Wall-clock milliseconds the last `onRender` took. */ + renderMs: number /** Current zoom factor and the canvas backing-store size. */ zoom: number canvasWidth: number @@ -141,7 +163,8 @@ const state: ActSceneState = { character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1, skippedDraws: 0, objects: 0, loadMs: 0, missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0, - drawCalls: 0, zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null, + drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0, + zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null, dialog: [], npcs: 0, npcsNear: [], } window.__d2webAct = state @@ -724,6 +747,29 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number const camera = new ViewportCamera(canvas, runtime.widthPx, runtime.heightPx) camera.attach() + // Culling bounds, built once: the draw lists are immutable for the life of the + // runtime, so the only per-frame cost is the rejection test itself. + const floorBounds = buildBounds(runtime.floors) + const wallBounds = buildBounds(runtime.walls) + const roofBounds = buildBounds(runtime.roofs) + const objectBounds = buildBounds(runtime.objectDrawables) + + // `runtime.pages` is filled in by background loaders, so the count has to be + // re-read every frame. A loop is used rather than `filter().length` because + // the latter allocates a throwaway array 60 times a second. + const countPages = (): number => { + let loaded = 0 + for (const page of runtime.pages) if (page !== null && page !== undefined) loaded += 1 + return loaded + } + + // NPC name plates. The container and the elements outlive a frame so the + // render loop only has to move them. + const labelsContainer = document.querySelector('#labels') + const labelPool: HTMLDivElement[] = [] + /** `performance.now()` at which the HUD text may be rewritten again. */ + let hudNextMs = 0 + let playerMoving = false let playerWalkFrame = 0 @@ -783,7 +829,9 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number const player = engine.world.player const movement = input.movement() - let playerMoving = movement.x !== 0 || movement.y !== 0 + // Assign, do not redeclare: `onRender` reads the outer `playerMoving` to + // pick the walk animation, and a shadowing `let` here left it stuck false. + playerMoving = movement.x !== 0 || movement.y !== 0 if (playerMoving) { playerWalkFrame = (playerWalkFrame + 1) % 24 } else { @@ -796,7 +844,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number state.facing = player.facing syncEngineState(engine, state) - + const dialogPanel = document.querySelector('#dialog') if (dialogPanel !== null) { dialogPanel.hidden = state.dialog.length === 0 @@ -804,21 +852,30 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number } }, onRender: () => { + const renderStarted = performance.now() if (state.pagesAtFirstFrame < 0) { - state.pagesAtFirstFrame = runtime.pages.filter(page => page !== null).length + state.pagesAtFirstFrame = countPages() state.firstFrameMs = Math.round(performance.now() - started) } - renderer.begin({ x: engine.world.player.x, y: engine.world.player.y - 16, zoom: camera.zoom }, [0.03, 0.03, 0.04]) + const camX = engine.world.player.x + const camY = engine.world.player.y - 16 + renderer.begin({ x: camX, y: camY, zoom: camera.zoom }, [0.03, 0.03, 0.04]) skippedDraws = 0 - const drawTiles = (list: readonly Drawable[], from: number, to: number): void => { - for (let index = from; index < to; index += 1) { + let culledDraws = 0 + + const view = viewportRect(camX, camY, canvas.width, canvas.height, camera.zoom) + const visible = (bounds: Float32Array, index: number): boolean => intersects(bounds, index, view) + + const drawTiles = (list: readonly Drawable[], bounds: Float32Array): void => { + for (let index = 0; index < list.length; index += 1) { + if (!visible(bounds, index)) { culledDraws += 1; continue } const draw = list[index]! const page = runtime.pages[draw.page] if (page === null || page === undefined) { skippedDraws += 1; continue } renderer.draw(draw.frame, draw.x, draw.y, { atlas: page }) } } - drawTiles(runtime.floors, 0, runtime.floors.length) + drawTiles(runtime.floors, floorBounds) const cell = cellOf(runtime.grid, engine.world.player.x, engine.world.player.y) const playerDepth = cell.x + cell.y @@ -837,20 +894,31 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number if (wallDepth > maxDepth && objDepth > maxDepth) break + // Culling only skips the `draw`; the cursors still advance, because the + // two lists are merged by depth and a stalled cursor would reorder + // everything behind it. if (wallDepth <= objDepth) { - const page = runtime.pages[nextWall!.page] - if (page === null || page === undefined) { - skippedDraws += 1 + if (!visible(wallBounds, wallIdx)) { + culledDraws += 1 } else { - renderer.draw(nextWall!.frame, nextWall!.x, nextWall!.y, { atlas: page }) + const page = runtime.pages[nextWall!.page] + if (page === null || page === undefined) { + skippedDraws += 1 + } else { + renderer.draw(nextWall!.frame, nextWall!.x, nextWall!.y, { atlas: page }) + } } wallIdx += 1 } else { - const page = runtime.objectPages[nextObj!.page] - if (page === null || page === undefined) { - skippedDraws += 1 + if (!visible(objectBounds, objIdx)) { + culledDraws += 1 } else { - renderer.draw(nextObj!.frame, nextObj!.x, nextObj!.y, { atlas: page }) + const page = runtime.objectPages[nextObj!.page] + if (page === null || page === undefined) { + skippedDraws += 1 + } else { + renderer.draw(nextObj!.frame, nextObj!.x, nextObj!.y, { atlas: page }) + } } objIdx += 1 } @@ -864,9 +932,9 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number depth: number draw: () => void } - + const entities: EntityDraw[] = [] - + const pushEntity = (x: number, y: number, draw: () => void) => { const c = cellOf(runtime.grid, x, y) entities.push({ x, y, depth: c.x + c.y, draw }) @@ -921,7 +989,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number } entities.sort((a, b) => a.depth - b.depth) - + for (const entity of entities) { drawUpToDepth(entity.depth) entity.draw() @@ -929,42 +997,62 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number drawUpToDepth(Infinity) // Roofs last, in their own pass: the engine paints them after every other // layer so they cover the floor, the walls and anything walking under them. - drawTiles(runtime.roofs, 0, runtime.roofs.length) + drawTiles(runtime.roofs, roofBounds) renderer.flush() - - const labelsContainer = document.querySelector('#labels') - if (labelsContainer) { - labelsContainer.innerHTML = '' + + // The labels are recycled rather than rebuilt: `innerHTML = ''` plus one + // `createElement` per NPC per frame forced a full style recalculation and + // made the DOM, not the GPU, the slowest part of some town frames. + if (labelsContainer !== null) { + let labelCount = 0 for (const npc of engine.npcEntities) { - const screenX = (npc.x - engine.world.player.x) * camera.zoom + canvas.width / 2 - const screenY = (npc.y - (engine.world.player.y - 16)) * camera.zoom + canvas.height / 2 - if (screenX > -100 && screenX < canvas.width + 100 && screenY > -100 && screenY < canvas.height + 100) { - const div = document.createElement('div') - div.textContent = npc.def.name - div.style.position = 'absolute' - div.style.left = `${screenX}px` - div.style.top = `${screenY - 60 * camera.zoom}px` - div.style.transform = 'translate(-50%, -100%)' - div.style.color = '#c8a15a' - div.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000' - div.style.fontSize = `${Math.max(10, 14 * camera.zoom)}px` - labelsContainer.appendChild(div) + const screenX = (npc.x - camX) * camera.zoom + canvas.width / 2 + const screenY = (npc.y - camY) * camera.zoom + canvas.height / 2 + if (screenX <= -100 || screenX >= canvas.width + 100) continue + if (screenY <= -100 || screenY >= canvas.height + 100) continue + let label = labelPool[labelCount] + if (label === undefined) { + label = document.createElement('div') + label.style.position = 'absolute' + label.style.transform = 'translate(-50%, -100%)' + label.style.color = '#c8a15a' + label.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000' + labelPool.push(label) + labelsContainer.appendChild(label) } + if (label.textContent !== npc.def.name) label.textContent = npc.def.name + label.style.left = `${screenX}px` + label.style.top = `${screenY - 60 * camera.zoom}px` + label.style.fontSize = `${Math.max(10, 14 * camera.zoom)}px` + label.hidden = false + labelCount += 1 } + for (let index = labelCount; index < labelPool.length; index += 1) labelPool[index]!.hidden = true } - - state.drawCalls = 1 + + state.drawCalls = renderer.drawCalls + state.quadsDrawn = renderer.quadsSubmitted + state.culledDraws = culledDraws state.tickRate = loop.tickRate state.zoom = Number(camera.zoom.toFixed(2)) state.canvasWidth = canvas.width state.canvasHeight = canvas.height - state.pagesLoaded = runtime.pages.filter(page => page !== null).length + state.pagesLoaded = countPages() state.skippedDraws = skippedDraws - hud.textContent = `${runtime.level} · ${runtime.quadrant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}` - + ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} 格 · ${String(runtime.floors.length)} 地面 / ${String(runtime.walls.length)} 墙` - + ` · 页 ${String(state.pagesLoaded)}/${String(state.pagesTotal)}` - + ` · 对象 ${String(runtime.objects)} · ${loop.tickRate.toFixed(1)} tps · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}` - + (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '') + state.renderMs = performance.now() - renderStarted + + // The HUD is a single text node, but rebuilding it means ten string + // concatenations and four `toFixed` calls; at 4 Hz that is invisible to the + // reader and stops the overlay from competing with the frame it describes. + if (renderStarted >= hudNextMs) { + hudNextMs = renderStarted + HUD_INTERVAL_MS + hud.textContent = `${runtime.level} · ${runtime.quadrant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}` + + ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} 格 · ${String(runtime.floors.length)} 地面 / ${String(runtime.walls.length)} 墙` + + ` · 页 ${String(state.pagesLoaded)}/${String(state.pagesTotal)}` + + ` · 对象 ${String(runtime.objects)} · ${loop.tickRate.toFixed(1)} tps · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}` + + ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})· ${state.renderMs.toFixed(1)} ms` + + (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '') + } }, }) loop.start() diff --git a/src/walk.ts b/src/walk.ts index 503a5fb..b5ddb89 100644 --- a/src/walk.ts +++ b/src/walk.ts @@ -247,7 +247,7 @@ async function start(archive: MpqArchive): Promise { renderer.draw(frame, player.x - frame.width / 2, player.y - frame.height + feetOffset) } renderer.flush() - state.drawCalls = 1 + state.drawCalls = renderer.drawCalls state.tickRate = loop.tickRate hud.textContent = `tick ${String(state.tick)} · ${loop.tickRate.toFixed(1)} tps` + ` · pos ${player.x.toFixed(0)},${player.y.toFixed(0)}` diff --git a/tests/cull.test.ts b/tests/cull.test.ts new file mode 100644 index 0000000..db77537 --- /dev/null +++ b/tests/cull.test.ts @@ -0,0 +1,182 @@ +import { describe, test, expect } from 'vitest' +import * as fs from 'node:fs' +import * as path from 'node:path' +import { buildBounds, intersects, viewportRect, CULL_MARGIN } from '../src/render/cull.ts' + +// 视口剔除的回归护栏。 +// +// 背景(见 issue #16):主循环原本把整张地图无条件提交给渲染器,实测 365 张烘焙 +// 关卡平均每帧 2 848 个四边形、最差 14 400 个,而真正落在视口里的只有 7%~11%。 +// 剔除本身不难,难的是**别把画家顺序弄坏**:墙和对象是按深度两路归并出来的, +// 一旦剔除时让某一路的游标停住,后面所有东西的前后遮挡关系就全乱了。 +// +// 所以这里除了边界条件,还用真实关卡数据做一次全量重放,断言"开剔除后画出来的 +// 序列,必须是不开剔除时那个序列的子序列"——顺序一致、只少不多、不多不错。 + +/** 一个最小可绘制物。 */ +function drawable(x: number, y: number, width: number, height: number) { + return { x, y, frame: { width, height } } +} + +describe('视口剔除', () => { + test('包围盒摊平成 [x0, y0, x1, y1]', () => { + const bounds = buildBounds([drawable(10, 20, 80, 40), drawable(-5, -6, 1, 2)]) + expect(Array.from(bounds)).toEqual([10, 20, 90, 60, -5, -6, -4, -4]) + }) + + test('空列表得到空包围盒', () => { + expect(buildBounds([]).length).toBe(0) + }) + + test('视口矩形与顶点着色器用同一个半宽公式', () => { + const view = viewportRect(1000, 500, 1280, 720, 2) + expect(view.left).toBe(1000 - 1280 / 4 - CULL_MARGIN) + expect(view.right).toBe(1000 + 1280 / 4 + CULL_MARGIN) + expect(view.top).toBe(500 - 720 / 4 - CULL_MARGIN) + expect(view.bottom).toBe(500 + 720 / 4 + CULL_MARGIN) + }) + + test('缩小时视口变大,放大时视口变小', () => { + const wide = viewportRect(0, 0, 1280, 720, 0.5) + const tight = viewportRect(0, 0, 1280, 720, 4) + expect(wide.right - wide.left).toBeGreaterThan(tight.right - tight.left) + }) + + test('视口内的留下,视口外的剔掉', () => { + const view = viewportRect(0, 0, 200, 200, 1) + // 半宽 100 + 余量 32 = 132。 + const bounds = buildBounds([ + drawable(0, 0, 10, 10), // 正中 + drawable(1000, 0, 10, 10), // 右边远处 + drawable(0, -5000, 10, 10), // 上方远处 + ]) + expect(intersects(bounds, 0, view)).toBe(true) + expect(intersects(bounds, 1, view)).toBe(false) + expect(intersects(bounds, 2, view)).toBe(false) + }) + + test('只有一角搭进视口也算可见', () => { + const view = viewportRect(0, 0, 200, 200, 1) + // 左上角在视口右下角外,但自身右下角刚好压线。 + const bounds = buildBounds([drawable(132, 132, 10, 10)]) + expect(intersects(bounds, 0, view)).toBe(true) + }) + + test('大精灵即使锚点在屏外也不能被剔掉', () => { + const view = viewportRect(0, 0, 200, 200, 1) + // 资源包里最大的帧是 160×960:锚点远在视口上方,画出来的部分却盖住整屏。 + const bounds = buildBounds([drawable(0, -900, 160, 960)]) + expect(intersects(bounds, 0, view)).toBe(true) + // 高度换成 10 之后就真的看不见了,说明上面那条靠的是帧尺寸而不是坐标。 + expect(intersects(buildBounds([drawable(0, -900, 160, 10)]), 0, view)).toBe(false) + }) + + test('余量让刚出界的一圈继续留下', () => { + const view = viewportRect(0, 0, 200, 200, 1) + // 半宽 100,落在 100~132 之间的只有靠余量才留得住。 + expect(intersects(buildBounds([drawable(120, 0, 1, 1)]), 0, view)).toBe(true) + expect(intersects(buildBounds([drawable(140, 0, 1, 1)]), 0, view)).toBe(false) + }) +}) + +/** 资源包位置;缺资源时整组测试跳过而不是报红。 */ +const PACKS = path.resolve(__dirname, '../samples/d2-packs') + +/** 所有含 scene.json 的关卡目录。 */ +function levelDirs(): { name: string; scene: string }[] { + if (!fs.existsSync(PACKS)) return [] + const out: { name: string; scene: string }[] = [] + for (const act of fs.readdirSync(PACKS)) { + const actPath = path.join(PACKS, act) + if (!fs.statSync(actPath).isDirectory()) continue + for (const level of fs.readdirSync(actPath)) { + const scene = path.join(actPath, level, 'scene.json') + if (fs.existsSync(scene)) out.push({ name: `${act}/${level}`, scene }) + } + } + return out +} + +interface Item { x: number; y: number; frame: { width: number; height: number }; depth: number } + +/** 把一张 scene.json 摊成场景实际持有的四条绘制列表。 */ +function loadLevel(scenePath: string) { + const json = JSON.parse(fs.readFileSync(scenePath, 'utf8')) as Record + const place = (json.framePlacement ?? []) as number[][] + const tiles = (rows: number[][] | undefined): Item[] => (rows ?? []).map(row => { + const [frameIndex, x, y, cellX, cellY] = row + const p = place[frameIndex!] ?? [0, 0, 0, 0, 0] + return { x: x!, y: y!, frame: { width: p[3]!, height: p[4]! }, depth: cellX! + cellY! } + }) + const objs: Item[] = (json.objects ?? []) + .filter((o: any) => o.frame) + .map((o: any) => ({ x: o.x, y: o.y, frame: { width: o.frame.width, height: o.frame.height }, depth: o.depth ?? 0 })) + .sort((a: Item, b: Item) => a.depth - b.depth) + const spawn = Array.isArray(json.spawn) ? { x: json.spawn[0] as number, y: json.spawn[1] as number } : { x: 0, y: 0 } + return { floors: tiles(json.floors), walls: tiles(json.walls), roofs: tiles(json.roofs), objs, spawn } +} + +/** + * 重放一张关卡的绘制顺序。 + * + * 这里刻意复刻 `act-scene` 的两路深度归并:剔除只跳过"画"这一步,游标照常前进。 + * + * @param cull - 是否启用视口剔除。 + * @returns 依次画出的标记,以及剔除数量。 + */ +function replay(level: ReturnType, cull: boolean) { + const view = viewportRect(level.spawn.x, level.spawn.y - 16, 1280, 720, 1) + const floorBounds = buildBounds(level.floors) + const wallBounds = buildBounds(level.walls) + const roofBounds = buildBounds(level.roofs) + const objBounds = buildBounds(level.objs) + + const order: string[] = [] + let culled = 0 + const emit = (bounds: Float32Array, index: number, item: Item, tag: string): void => { + if (cull && !intersects(bounds, index, view)) { culled += 1; return } + order.push(`${tag}:${String(item.x)},${String(item.y)}`) + } + + for (let i = 0; i < level.floors.length; i += 1) emit(floorBounds, i, level.floors[i]!, 'f') + let wi = 0 + let oi = 0 + while (wi < level.walls.length || oi < level.objs.length) { + const wd = wi < level.walls.length ? level.walls[wi]!.depth : Infinity + const od = oi < level.objs.length ? level.objs[oi]!.depth : Infinity + if (wd <= od) { emit(wallBounds, wi, level.walls[wi]!, 'w'); wi += 1 } + else { emit(objBounds, oi, level.objs[oi]!, 'o'); oi += 1 } + } + for (let i = 0; i < level.roofs.length; i += 1) emit(roofBounds, i, level.roofs[i]!, 'r') + return { order, culled } +} + +/** `sub` 是否按相同相对顺序出现在 `full` 里。 */ +function isSubsequence(sub: string[], full: string[]): boolean { + let i = 0 + for (const item of full) if (i < sub.length && sub[i] === item) i += 1 + return i === sub.length +} + +const levels = levelDirs() + +describe.runIf(levels.length > 0)('剔除后的画家顺序(全量资源包重放)', () => { + test('每张关卡剔除后的绘制序列都是原序列的子序列', () => { + const broken: string[] = [] + let before = 0 + let after = 0 + for (const { name, scene } of levels) { + const level = loadLevel(scene) + const full = replay(level, false) + const culled = replay(level, true) + if (!isSubsequence(culled.order, full.order)) broken.push(name) + before += full.order.length + after += culled.order.length + } + // 具名收集,失败时能直接看出是哪几张关卡乱了序。 + expect(broken).toEqual([]) + // 顺带守住收益:真剔掉了绝大多数,而不是悄悄退化成"全画"。 + expect(after).toBeGreaterThan(0) + expect(after).toBeLessThan(before * 0.25) + }) +})