perf(render): 视口剔除 + 单批次合图 + 零分配顶点写入 (refs #16)

主循环掉到 20 tps 的原因不是"缺引擎",而是三件具体的事,这个改动把它们一次做掉。

1. 视口剔除。此前整张地图无条件提交:把 act-scene 的绘制顺序在 365 张烘焙关卡上
   静态重放,平均每帧 2 848 个四边形、最差 14 400 个(act4 迪亚波罗),而真正落在
   1280x720 视口里的只有 7%~11%。现在关卡建好时把每条绘制列表的包围盒预先摊平成
   一块 Float32Array,每帧线性扫描做四次浮点比较。实测平均 2 848 -> 204 个/帧
   (7.2%),最差 14 400 -> 437 个。

   之所以是线性扫描而不是二分或分桶:绘制列表并不按 y 单调(地面 0/365 单调,最大
   回退 9 520 px),任何空间索引都得重新合并回画家顺序。14 400 次比较约 15~30 µs,
   占 16 ms 预算的 0.2%,换来的是顺序原样保留。墙和对象那条两路深度归并里,剔除只
   跳过"画"这一步,游标照常前进——否则后面所有遮挡关系都会错位。

2. 一次 draw call。此前批次一遇到图集页切换就得断开,同一帧里页号来回横跳,实测
   平均 361 次、最差 3 077 次 draw call。现在每个在用的图集页各占一个纹理单元,
   单元号随顶点属性进着色器,片元按 switch 选采样器,只有在用页数超过
   MAX_TEXTURE_IMAGE_UNITS 时才断批。

   这里没有采用 issue 里原先设想的 TEXTURE_2D_ARRAY:页高差异极大(2048x1999 到
   2048x48),纹理数组要求各层等大,实测朴素做法显存涨 1.73 倍(最差 2.82 倍,单关
   156.8 MB),装箱后仍要 1.29 倍。而全部 365 张关卡里单关最多只有 10 个页,加角色
   图集 11 个,稳稳低于 WebGL2 保证的 16 个单元——多单元合批同样是 1 次 draw call,
   显存零增长,资源也不用重烘焙。

3. 零分配顶点写入。quad() 原本每次建 4 个临时数组再解构,实测 2 849 个四边形写一帧
   要 244.9 µs;展开成标量后 106.0 µs(2.31 倍),每帧少建 11 396 个临时数组。同时
   顶点从 6 个/四边形降到 4 个并改用索引缓冲,上传前先 orphan 一次缓冲区避免与 GPU
   抢同一块内存。

顺带修掉几个让问题一直看不见、或者纯属每帧浪费的地方:

- state.drawCalls 原本在 act-scene 和 walk 里都硬编码成 1,这个假读数正是问题长期
  没被发现的原因;现在读渲染器自己的计数,HUD 一并显示图元数、剔除数和帧耗时。
- pages.filter(...).length 每帧建一个临时数组只为数个数,改成循环。
- HUD 文本每帧重建(十次字符串拼接 + 四次 toFixed),改成 4 Hz。
- NPC 名牌每帧 innerHTML='' 再 createElement,改成复用元素只改位置。
- onTick 里的 playerMoving 用 let 遮蔽了外层同名变量,导致 onRender 永远读到 false、
  角色走路动画从不播放。改成赋值。

护栏:新增 tests/cull.test.ts,除边界条件外,用全部 365 张关卡真实数据重放一遍,
断言"开剔除后画出的序列必须是不开剔除时那个序列的子序列"(顺序一致、只少不多),
并守住剔除率。verify-renderer-lifecycle 从 26 条加到 37 条,锁住"60 个四边形在 3 个
页之间来回切只花 1 次 draw call"、超出单元预算恰好多花 1 次、计数器在 begin() 归零、
以及着色器里采样器数组的形状。

Refs #16
This commit is contained in:
troytt 2026-09-15 01:30:39 +00:00
parent bbc1af7a72
commit 4cc49a01aa
6 changed files with 820 additions and 125 deletions

View File

@ -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<MockResource>
deletedShaders: Set<MockResource>
eventListeners: Map<string, Set<EventListener>>
glDrawCalls: number
shaderSources: string[]
}
setContextLost: (lost: boolean) => void
dispatchMockEvent: (type: string) => { defaultPrevented: boolean }
@ -42,6 +45,8 @@ function createMockCanvas(): {
createdShaders: new Set<MockResource>(),
deletedShaders: new Set<MockResource>(),
eventListeners: new Map<string, Set<EventListener>>(),
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<SpriteRenderer['addAtlas']> {
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)
}

106
src/render/cull.ts Normal file
View File

@ -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
}

View File

@ -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<WebGLTexture>()
/** 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<AtlasHandle | null>(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.

View File

@ -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<HTMLElement>('#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<HTMLElement>('#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<HTMLElement>('#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()

View File

@ -247,7 +247,7 @@ async function start(archive: MpqArchive): Promise<void> {
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)}`

182
tests/cull.test.ts Normal file
View File

@ -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<string, any>
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<typeof loadLevel>, 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)
})
})