import { type FrameRect, isTextureFile, resolvePicture } from '../../core/model/texture'; import { effectiveScale, gameClock } from '../../core/time'; import { afterFrames, isRendering, renderCount, requestRender, stagePixel } from '../../ui'; import { particleLibrary } from '../../viewport/ParticleLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { textureLibrary } from '../../viewport/TextureLibrary'; import { type Container, Mesh } from 'pixi.js'; import type { HookContext } from './hookHelpers'; /** What was really drawn, and the clocks behind it: pixels, textures, frames, shaders and particles. */ export function renderHooks({ app, state, editor }: HookContext) { const { viewport, gameView } = editor; const pixelAt = (x: number, y: number): string => stagePixel(app, x, y); return { pixelAt, /** * A horizontal run of pixels in one read. A check that has to tell a repeated pattern from a * stretched one needs dozens of samples, and dozens of round trips would cost more than the * step it belongs to. */ pixelRow: (x: number, y: number, count: number) => { const width = Math.max(1, Math.min(1024, Math.round(count))); const row: string[] = []; // One extract per pixel, not one strip: a wider frame comes back resampled, and a row that // blends its neighbours cannot tell a hard border from a soft one – which is what it is for. for (let i = 0; i < width; i++) row.push(pixelAt(x + i, y)); return row; }, /** Every uploaded picture the project names and whether it is in memory, drawn anywhere or not. */ heldPictures: () => state.project.assets.flatMap((asset) => asset.type === 'texture' && isTextureFile(asset.file) ? [{ name: asset.name, held: textureLibrary.peek(asset.file) !== null }] : [], ), /** The filter and wrap the GPU sampler actually got, next to the model's – only the driver knows the former. */ textureFilter: (file: string, rect: FrameRect | null = null) => { const texture = textureLibrary.peek(file, rect); if (!texture) return null; const source = texture.source; const renderer = app.renderer as unknown as { gl?: WebGL2RenderingContext; uid: number; texture: { bindSource(source: unknown, location: number): void }; }; const gl = renderer.gl; const uploaded = Boolean( (source as unknown as { _gpuData: Record })._gpuData[renderer.uid], ); let magFilter = 0; let wrapT = 0; if (gl) { // Through Pixi rather than a raw `bindTexture`, so its idea of what is bound stays true. renderer.texture.bindSource(source, 0); magFilter = gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER) as number; wrapT = gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T) as number; } return { scaleMode: source.scaleMode, gpu: magFilter === gl?.NEAREST ? 'nearest' : magFilter === gl?.LINEAR ? 'linear' : 'none', wrap: wrapT === gl?.CLAMP_TO_EDGE ? 'clamp' : wrapT === gl?.REPEAT ? 'repeat' : wrapT === gl?.MIRRORED_REPEAT ? 'mirror' : 'none', uploaded, }; }, /** * A picture an asset draws itself (#129, #185): the key it is kept under, the canvas the model * says it is, and the size it actually lies on the GPU in – which is the only place the * supersampling of the rasteriser is visible. `kept` false means the key is not in memory, * which is what a check reads to see an old revision let go. */ drawnPicture: (assetId: string) => { const ref = resolvePicture(state.project, assetId); if (!ref) return null; const texture = textureLibrary.peek(ref.file); return { file: ref.file, width: ref.sourceWidth, height: ref.sourceHeight, pixelWidth: texture?.source.pixelWidth ?? 0, pixelHeight: texture?.source.pixelHeight ?? 0, kept: texture !== null, }; }, fps: () => editor.fps, /** * What the render loop did: frames actually drawn, and whether it is currently awake. The * only way to see a missing repaint from a check script – `pixelAt` re-renders on demand and * would answer correctly even if the screen had been frozen for a minute. */ renderStats: () => ({ drawn: renderCount(), awake: isRendering() }), /** Draw again, whatever happens next – what a screenshot needs before it is taken. */ wake: () => requestRender(), /** * Resolves once the editor has drawn `count` more frames. The checks await this instead of * sleeping a guessed number of milliseconds: a frame is the editor saying it has caught up, * where a deadline is only the harness hoping it has. */ afterFrames: (count = 2, timeoutMs = 2000) => afterFrames(count, timeoutMs), /** Visible nodes carrying a filter – one extra render pass each; since #77 a shader is none. */ filterCount: () => { let n = 0; const walk = (node: Container): void => { if (!node.visible) return; if (((node.filters as readonly unknown[] | undefined)?.length ?? 0) > 0) n += 1; for (const child of node.children) walk(child); }; walk(app.stage); return n; }, /** Visible sprite meshes drawing with a shader of their own – one draw call each, no pass (#77). */ shaderCount: () => { let n = 0; const walk = (node: Container): void => { if (!node.visible) return; if (node instanceof Mesh && node.shader) n += 1; for (const child of node.children) walk(child); }; walk(app.stage); return n; }, /** The global clock: the second it stands at, the scale set, and the one a pause – either kind – makes of it. */ globalTime: () => ({ now: gameClock.now, scale: gameClock.scale, effective: effectiveScale(gameClock.scale, state.paused || editor.playInfo().gamePaused), }), /** Sets the global time scale – there is no slider for it in the GUI yet. */ setTimeScale: (scale: number) => { gameClock.scale = scale; }, /** Holds the shader clock at `at` seconds, so a Time shader's pixels can be measured; null releases it. */ shaderTime: (at: number | null) => shaderLibrary.freezeTime(at), /** The same for particle time, kept apart so one can be held without the other. */ particleTime: (at: number | null) => particleLibrary.freezeTime(at), /** Every source's clock back to zero on the current (usually frozen) second. */ particlesRestart: () => { viewport.restartParticles(particleLibrary.now()); gameView.restartParticles(particleLibrary.now()); }, /** What a source is drawing in the scene tab, or in the game view while playing. */ particlesOf: (objectId: string, where: 'scene' | 'game' = 'scene', limit = 8) => (where === 'game' ? gameView : viewport).particleInfo(objectId, limit), /** Graphs compiled so far; a moved node must not add one (#65). */ shaderCompiles: () => shaderLibrary.compiles, /** Which views redraw every tick because a Time shader is on screen. */ animates: () => ({ scene: viewport.animates, game: gameView.animates }), lowLatency: () => (app.renderer as unknown as { gl?: WebGL2RenderingContext }).gl?.getContextAttributes() ?.desynchronized === true, }; }