UNPKG

@spearwolf/twopoint5d

Version:

Create 2.5D realtime graphics and pixelart with WebGL and three.js

524 lines 24.3 kB
import { Color } from 'three/webgpu'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { OnAddToParent, OnRemoveFromParent, OnStageAdded, OnStageRemoved } from '../events.js'; import { StageRenderer } from './StageRenderer.js'; import { on } from '@spearwolf/eventize'; function createRendererMock() { const m = { autoClear: true, __clearColor: new Color(0x111111), __clearAlpha: 0.5, __renderTarget: null, setClearColor: vi.fn(), getClearColor: vi.fn(), setClearAlpha: vi.fn(), getClearAlpha: vi.fn(), clear: vi.fn(), render: vi.fn(), setRenderTarget: vi.fn(), getRenderTarget: vi.fn(), getPixelRatio: vi.fn(() => 1), }; m.setRenderTarget.mockImplementation((rt) => { m.__renderTarget = rt; }); m.getRenderTarget.mockImplementation(() => m.__renderTarget); m.setClearColor.mockImplementation((c, a) => { m.__clearColor.copy(c); if (typeof a === 'number') m.__clearAlpha = a; }); m.getClearColor.mockImplementation((out) => out.copy(m.__clearColor)); m.setClearAlpha.mockImplementation((a) => { m.__clearAlpha = a; }); m.getClearAlpha.mockImplementation(() => m.__clearAlpha); return m; } function fakeStage(name) { return { name, resize: vi.fn(), updateFrame: vi.fn(), renderTo: vi.fn(), }; } describe('StageRenderer', () => { let renderer; beforeEach(() => { renderer = createRendererMock(); }); describe('clear policy', () => { it('does not clear by default', () => { new StageRenderer().renderTo(renderer); expect(renderer.clear).not.toHaveBeenCalled(); }); it('clears once when clear=true with explicit color', () => { const sr = new StageRenderer(); sr.setClearColor(new Color('#112233'), 0.25); sr.renderTo(renderer); expect(renderer.clear).toHaveBeenCalledTimes(1); expect(renderer.clear).toHaveBeenCalledWith(true, true, true); const callArgs = renderer.setClearColor.mock.calls[0]; expect(callArgs[0]).toBeInstanceOf(Color); expect(callArgs[0].getHexString()).toBe('112233'); expect(callArgs[1]).toBe(0.25); }); it('clears with alpha only when clear=true and clearColor=null', () => { const sr = new StageRenderer(); sr.clear = true; sr.clearAlpha = 0; sr.renderTo(renderer); expect(renderer.clear).toHaveBeenCalledTimes(1); expect(renderer.setClearColor).not.toHaveBeenCalled(); expect(renderer.setClearAlpha).toHaveBeenCalledWith(0); }); it('restores prior clear color and alpha after clearing', () => { const sr = new StageRenderer(); sr.setClearColor(new Color('#aabbcc'), 1); renderer.__clearColor.set('#445566'); renderer.__clearAlpha = 0.42; sr.renderTo(renderer); const restoreCall = renderer.setClearColor.mock.calls.at(-1); expect(restoreCall[0].getHexString()).toBe('445566'); expect(restoreCall[1]).toBe(0.42); }); it('does not touch renderer.setClearAlpha when clear=false', () => { new StageRenderer().renderTo(renderer); expect(renderer.setClearAlpha).not.toHaveBeenCalled(); expect(renderer.setClearColor).not.toHaveBeenCalled(); }); it('setClearColor flips clear=true and is fluent', () => { const sr = new StageRenderer(); expect(sr.clear).toBe(false); const ret = sr.setClearColor(new Color('#abcdef')); expect(ret).toBe(sr); expect(sr.clear).toBe(true); }); it('assigning clearColor to a Color implicitly activates clear', () => { const sr = new StageRenderer(); sr.clearColor = new Color('#ff00ff'); expect(sr.clear).toBe(true); }); it('assigning clearColor to null does not toggle clear off', () => { const sr = new StageRenderer(); sr.clear = true; sr.clearColor = null; expect(sr.clear).toBe(true); expect(sr.clearColor).toBeNull(); }); it('honors clearColorBuffer / clearDepthBuffer / clearStencilBuffer flags', () => { const sr = new StageRenderer(); sr.setClearColor(new Color('#000')); sr.clearColorBuffer = false; sr.clearStencilBuffer = false; sr.renderTo(renderer); expect(renderer.clear).toHaveBeenCalledWith(false, true, false); }); }); describe('rendering', () => { it('delegates to each stage.renderTo() in renderOrder', () => { const sr = new StageRenderer(); const a = fakeStage('a'); const b = fakeStage('b'); sr.add(a).add(b); sr.renderTo(renderer); expect(a.renderTo).toHaveBeenCalledTimes(1); expect(b.renderTo).toHaveBeenCalledTimes(1); const callOrderA = a.renderTo.mock.invocationCallOrder[0]; const callOrderB = b.renderTo.mock.invocationCallOrder[0]; expect(callOrderA).toBeLessThan(callOrderB); }); it('forces renderer.autoClear = false while iterating stages', () => { renderer.autoClear = true; const sr = new StageRenderer(); const stage = fakeStage('s'); stage.renderTo.mockImplementation(() => { expect(renderer.autoClear).toBe(false); }); sr.add(stage).renderTo(renderer); expect(renderer.autoClear).toBe(true); }); it('respects renderOrder', () => { const sr = new StageRenderer(); const ui = fakeStage('ui'); const world = fakeStage('world'); const debug = fakeStage('debug'); sr.add(ui).add(world).add(debug); sr.renderOrder = 'world,*,debug'; sr.renderTo(renderer); const order = [world, ui, debug].map((s) => s.renderTo.mock.invocationCallOrder[0]); expect(order[0]).toBeLessThan(order[1]); expect(order[1]).toBeLessThan(order[2]); }); }); describe('add / remove', () => { it('is fluent and emits OnStageAdded / OnStageRemoved', () => { const sr = new StageRenderer(); const added = vi.fn(); const removed = vi.fn(); on(sr, OnStageAdded, added); on(sr, OnStageRemoved, removed); const stage = fakeStage('one'); expect(sr.add(stage)).toBe(sr); expect(added).toHaveBeenCalledWith({ stage, renderer: sr }); expect(sr.remove(stage)).toBe(sr); expect(removed).toHaveBeenCalledWith({ stage, renderer: sr }); }); it('add() is idempotent', () => { const sr = new StageRenderer(); const stage = fakeStage('x'); sr.add(stage); sr.add(stage); expect(sr.stages.length).toBe(1); }); it('warns on duplicate name when renderOrder is non-default', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => { }); const sr = new StageRenderer(); sr.renderOrder = 'a,b'; sr.add(fakeStage('a')); sr.add(fakeStage('a')); expect(warn).toHaveBeenCalledTimes(1); warn.mockRestore(); }); it('does NOT warn on duplicate name when renderOrder is default "*"', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => { }); const sr = new StageRenderer(); sr.add(fakeStage('a')); sr.add(fakeStage('a')); expect(warn).not.toHaveBeenCalled(); warn.mockRestore(); }); it('propagates resize() to stages', () => { const sr = new StageRenderer(); const stage = fakeStage('x'); sr.add(stage); sr.resize(320, 240); expect(stage.resize).toHaveBeenLastCalledWith(320, 240); }); }); describe('parent / host wiring (3.7)', () => { function makeHost() { let resizeHandler; let frameHandler; let unsubs = 0; const unsub = () => { unsubs += 1; }; return { onResize: (h) => { resizeHandler = h; return unsub; }, onRenderFrame: (h) => { frameHandler = h; return unsub; }, _emitResize(w, h) { resizeHandler({ width: w, height: h, renderer, now: 0, deltaTime: 0, frameNo: 1 }); }, _emitFrame(now, dt, frameNo) { frameHandler({ renderer, now, deltaTime: dt, frameNo }); }, get _unsubs() { return unsubs; }, }; } it('auto-drives resize + updateFrame + renderTo via a custom host', () => { const host = makeHost(); const sr = new StageRenderer(host); const stage = fakeStage('s'); sr.add(stage); host._emitResize(100, 50); expect(sr.width).toBe(100); expect(stage.resize).toHaveBeenCalledWith(100, 50); host._emitFrame(1, 0.016, 1); expect(stage.updateFrame).toHaveBeenCalledWith(1, 0.016, 1); expect(stage.renderTo).toHaveBeenCalledTimes(1); }); it('attach() returns this and detach() unsubscribes', () => { const host = makeHost(); const sr = new StageRenderer(); expect(sr.attach(host)).toBe(sr); expect(host._unsubs).toBe(0); sr.detach(); expect(host._unsubs).toBe(2); }); it('nesting: child StageRenderer is added as a stage of the parent', () => { const parent = new StageRenderer(); const child = new StageRenderer(parent); expect(parent.hasStage(child)).toBe(true); const inner = fakeStage('inner'); child.add(inner); parent.renderTo(renderer); expect(inner.renderTo).toHaveBeenCalledTimes(1); }); it('emits OnAddToParent and OnRemoveFromParent on the child', () => { const host = makeHost(); const sr = new StageRenderer(); const added = vi.fn(); const removed = vi.fn(); on(sr, OnAddToParent, added); on(sr, OnRemoveFromParent, removed); sr.attach(host); expect(added).toHaveBeenCalledTimes(1); sr.detach(); expect(removed).toHaveBeenCalledTimes(1); }); }); describe('outputRenderTarget (§6.4 RT only, no pipeline)', () => { it('redirects rendering into the given RT and restores the previous target', () => { const sr = new StageRenderer(); const rt = { isRenderTarget: true }; sr.outputRenderTarget = rt; const stage = fakeStage('s'); stage.renderTo.mockImplementation(() => { expect(renderer.__renderTarget).toBe(rt); }); sr.add(stage); const beforeRT = { tag: 'screen' }; renderer.__renderTarget = beforeRT; sr.renderTo(renderer); expect(stage.renderTo).toHaveBeenCalledTimes(1); expect(renderer.__renderTarget).toBe(beforeRT); }); }); describe('pipeline without buildOutputNode (§6.4 Mode C)', () => { function makePipelineMock() { return { outputNode: undefined, needsUpdate: false, render: vi.fn(), dispose: vi.fn(), }; } it('renders stages into an internal RT, then runs the pipeline', () => { const sr = new StageRenderer(); sr.resize(200, 100); const stage = fakeStage('s'); sr.add(stage); const pipeline = makePipelineMock(); sr.pipeline = pipeline; let rtDuringStageRender; stage.renderTo.mockImplementation(() => { rtDuringStageRender = renderer.__renderTarget; }); let rtDuringPipelineRender; pipeline.render.mockImplementation(() => { rtDuringPipelineRender = renderer.__renderTarget; }); sr.renderTo(renderer); expect(rtDuringStageRender).not.toBeNull(); expect(rtDuringStageRender?.isRenderTarget).toBe(true); expect(rtDuringPipelineRender).toBeNull(); expect(pipeline.render).toHaveBeenCalledTimes(1); expect(pipeline.needsUpdate).toBe(true); expect(pipeline.outputNode).toBeDefined(); }); it('rebuilds outputNode only when stage list changes', () => { const sr = new StageRenderer(); sr.resize(100, 100); const stage = fakeStage('s'); sr.add(stage); const pipeline = { outputNode: undefined, needsUpdate: false, render: vi.fn(), dispose: vi.fn() }; sr.pipeline = pipeline; sr.renderTo(renderer); const firstNode = pipeline.outputNode; pipeline.needsUpdate = false; sr.renderTo(renderer); expect(pipeline.outputNode).toBe(firstNode); expect(pipeline.needsUpdate).toBe(false); sr.add(fakeStage('t')); pipeline.needsUpdate = false; sr.renderTo(renderer); expect(pipeline.needsUpdate).toBe(true); }); it('runs pipeline into outputRenderTarget when set', () => { const sr = new StageRenderer(); sr.resize(100, 100); sr.add(fakeStage('s')); const outRT = { isRenderTarget: true, tag: 'out' }; sr.outputRenderTarget = outRT; let rtDuringPipeline; const pipeline = { outputNode: undefined, needsUpdate: false, render: vi.fn(() => { rtDuringPipeline = renderer.__renderTarget; }), dispose: vi.fn(), }; sr.pipeline = pipeline; sr.renderTo(renderer); expect(rtDuringPipeline).toBe(outRT); }); it('dispose() releases internal RT and pipeline', () => { const sr = new StageRenderer(); sr.resize(50, 50); sr.add(fakeStage('s')); const dispose = vi.fn(); sr.pipeline = { outputNode: undefined, needsUpdate: false, render: vi.fn(), dispose }; sr.renderTo(renderer); sr.dispose(); expect(dispose).toHaveBeenCalledTimes(1); expect(sr.pipeline).toBeUndefined(); }); }); describe('asPassNode + buildOutputNode (§6.2 / §6.3)', () => { function fakePassNode(label) { return { isNode: true, label, type: 'pass' }; } it('Stage2D.asPassNode requires a camera (throws without projection)', async () => { const { Stage2D } = await import('./Stage2D.js'); const { ParallaxProjection } = await import('./ParallaxProjection.js'); const stage = new Stage2D(); expect(() => stage.asPassNode(renderer)).toThrowError(/no scene or camera/); stage.projection = new ParallaxProjection('xy|bottom-left'); expect(() => stage.asPassNode(renderer)).not.toThrow(); }); it('buildOutputNode receives a pass node per stage (default renderOrder = "*", insertion order)', () => { const sr = new StageRenderer(); sr.resize(100, 100); const passA = fakePassNode('a'); const passB = fakePassNode('b'); const stageA = { ...fakeStage('a'), asPassNode: vi.fn(() => passA) }; const stageB = { ...fakeStage('b'), asPassNode: vi.fn(() => passB) }; sr.add(stageA).add(stageB); const buildOutputNode = vi.fn((nodes) => nodes[0]); sr.buildOutputNode = buildOutputNode; sr.pipeline = { outputNode: undefined, needsUpdate: false, render: vi.fn(), dispose: vi.fn() }; sr.renderTo(renderer); expect(buildOutputNode).toHaveBeenCalledTimes(1); expect(buildOutputNode.mock.calls[0][0]).toEqual([passA, passB]); }); describe('renderOrder controls the order of pass nodes passed to buildOutputNode', () => { function makeOrderedSetup(names, renderOrder) { const sr = new StageRenderer(); sr.resize(100, 100); const passByName = {}; for (const n of names) { passByName[n] = fakePassNode(n); const stage = { ...fakeStage(n), asPassNode: vi.fn(() => passByName[n]) }; sr.add(stage); } if (renderOrder !== undefined) sr.renderOrder = renderOrder; const buildOutputNode = vi.fn((nodes) => nodes[0]); sr.buildOutputNode = buildOutputNode; sr.pipeline = { outputNode: undefined, needsUpdate: false, render: vi.fn(), dispose: vi.fn() }; return { sr, buildOutputNode, passByName }; } it('explicit list reorders inserted stages: "ui,world,bg" → passes [ui, world, bg]', () => { const { sr, buildOutputNode, passByName } = makeOrderedSetup(['bg', 'world', 'ui'], 'ui,world,bg'); sr.renderTo(renderer); expect(buildOutputNode.mock.calls[0][0]).toEqual([passByName['ui'], passByName['world'], passByName['bg']]); }); it('wildcard splices the rest in insertion order: "ui,*" → [ui, bg, world]', () => { const { sr, buildOutputNode, passByName } = makeOrderedSetup(['bg', 'world', 'ui'], 'ui,*'); sr.renderTo(renderer); expect(buildOutputNode.mock.calls[0][0]).toEqual([passByName['ui'], passByName['bg'], passByName['world']]); }); it('wildcard between names: "bg,*,ui" → [bg, world, ui]', () => { const { sr, buildOutputNode, passByName } = makeOrderedSetup(['bg', 'world', 'ui'], 'bg,*,ui'); sr.renderTo(renderer); expect(buildOutputNode.mock.calls[0][0]).toEqual([passByName['bg'], passByName['world'], passByName['ui']]); }); it('names missing from renderOrder are dropped from the pass list: "ui,bg" → [ui, bg] (world omitted)', () => { const { sr, buildOutputNode, passByName } = makeOrderedSetup(['bg', 'world', 'ui'], 'ui,bg'); sr.renderTo(renderer); expect(buildOutputNode.mock.calls[0][0]).toEqual([passByName['ui'], passByName['bg']]); }); it('unknown names in renderOrder are ignored: "ui,nope,world,*" → [ui, world, bg]', () => { const { sr, buildOutputNode, passByName } = makeOrderedSetup(['bg', 'world', 'ui'], 'ui,nope,world,*'); sr.renderTo(renderer); expect(buildOutputNode.mock.calls[0][0]).toEqual([passByName['ui'], passByName['world'], passByName['bg']]); }); it('whitespace in renderOrder is trimmed: " ui , world , bg " → [ui, world, bg]', () => { const { sr, buildOutputNode, passByName } = makeOrderedSetup(['bg', 'world', 'ui'], ' ui , world , bg '); sr.renderTo(renderer); expect(buildOutputNode.mock.calls[0][0]).toEqual([passByName['ui'], passByName['world'], passByName['bg']]); }); it('changing renderOrder after the first render rebuilds outputNode with the new order', () => { const { sr, buildOutputNode, passByName } = makeOrderedSetup(['bg', 'world', 'ui'], 'bg,world,ui'); sr.renderTo(renderer); expect(buildOutputNode).toHaveBeenCalledTimes(1); expect(buildOutputNode.mock.calls[0][0]).toEqual([passByName['bg'], passByName['world'], passByName['ui']]); sr.renderOrder = 'ui,world,bg'; sr.renderTo(renderer); expect(buildOutputNode).toHaveBeenCalledTimes(2); expect(buildOutputNode.mock.calls[1][0]).toEqual([passByName['ui'], passByName['world'], passByName['bg']]); }); it('order matches the parallel call order of asPassNode() per stage', () => { const sr = new StageRenderer(); sr.resize(100, 100); const order = []; const make = (name) => { const node = fakePassNode(name); const stage = { ...fakeStage(name), asPassNode: vi.fn(() => { order.push(name); return node; }), }; return { stage, node }; }; const a = make('a'); const b = make('b'); const c = make('c'); sr.add(a.stage).add(b.stage).add(c.stage); sr.renderOrder = 'c,a,b'; const buildOutputNode = vi.fn((nodes) => nodes[0]); sr.buildOutputNode = buildOutputNode; sr.pipeline = { outputNode: undefined, needsUpdate: false, render: vi.fn(), dispose: vi.fn() }; sr.renderTo(renderer); expect(order).toEqual(['c', 'a', 'b']); expect(buildOutputNode.mock.calls[0][0]).toEqual([c.node, a.node, b.node]); }); }); it('throws when a stage in the build path has no asPassNode()', () => { const sr = new StageRenderer(); sr.resize(100, 100); sr.add(fakeStage('bare')); sr.buildOutputNode = ((nodes) => nodes[0]); sr.pipeline = { outputNode: undefined, needsUpdate: false, render: vi.fn(), dispose: vi.fn() }; expect(() => sr.renderTo(renderer)).toThrowError(/asPassNode/); }); it('nested StageRenderer is pre-rendered into its asPassNode-RT before parent pipeline runs', () => { const parent = new StageRenderer(); parent.resize(100, 100); const child = new StageRenderer(); parent.add(child); child.resize(100, 100); const inner = fakeStage('inner'); child.add(inner); let rtDuringInner; inner.renderTo.mockImplementation(() => { rtDuringInner = renderer.__renderTarget; }); let pipelineOutputNode; parent.buildOutputNode = ((nodes) => { pipelineOutputNode = nodes[0]; return nodes[0]; }); parent.pipeline = { outputNode: undefined, needsUpdate: false, render: vi.fn(), dispose: vi.fn() }; parent.renderTo(renderer); expect(rtDuringInner).not.toBeNull(); expect(rtDuringInner?.isRenderTarget).toBe(true); expect(pipelineOutputNode).toBeDefined(); }); it('invalidateOutputNode() forces a rebuild on next render', () => { const sr = new StageRenderer(); sr.resize(100, 100); sr.add(fakeStage('a')); sr.pipeline = { outputNode: undefined, needsUpdate: false, render: vi.fn(), dispose: vi.fn() }; sr.renderTo(renderer); sr.pipeline.needsUpdate = false; sr.invalidateOutputNode(); sr.renderTo(renderer); expect(sr.pipeline.needsUpdate).toBe(true); }); }); }); //# sourceMappingURL=StageRenderer.spec.js.map