import { type FrameRect, isTextureFile, resolvePicture } from '../../core/model/texture'; import { effectiveScale, gameClock } from '../../core/time'; import { afterFrames, hairline, isRendering, renderCount, requestRender, stagePixel, stagePixels, } 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'; import { edgeContrast } from './sharpness'; /** 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); /** * A part of the frame as the screen gets it, device pixel for device pixel. `pixelAt` cannot say * that: it draws the stage again into a target of its own, at the layout's size. */ const deviceFrame = (x: number, y: number, w: number, h: number): ImageData => { const res = app.renderer.resolution; const [sx, sy, sw, sh] = [x, y, w, h].map((v) => Math.round(v * res)) as [number, number, number, number]; const copy = document.createElement('canvas'); copy.width = Math.max(1, sw); copy.height = Math.max(1, sh); const context = copy.getContext('2d')!; // In one go with the render: the drawing buffer is only promised to hold the frame until the task ends. app.render(); context.drawImage(app.canvas, sx, sy, copy.width, copy.height, 0, 0, copy.width, copy.height); return context.getImageData(0, 0, copy.width, copy.height); }; return { pixelAt, /** The grid the interface rounds to: device pixels per layout pixel, and what a hairline measures. */ pixelGrid: () => ({ resolution: app.renderer.resolution, hairline: hairline() }), /** How hard the edges are in a rectangle of the screen, given in layout pixels – see `edgeContrast`. */ edgeContrast: (x: number, y: number, w: number, h: number) => { const frame = deviceFrame(x, y, w, h); return edgeContrast(frame.data, frame.width, frame.height); }, /** The device pixels down one column, from layout `y` over `h`, as `#rrggbb`. */ deviceColumn: (x: number, y: number, h: number) => { const { data, height } = deviceFrame(x, y, 1 / app.renderer.resolution, h); const hex = (v: number | undefined) => (v ?? 0).toString(16).padStart(2, '0'); return Array.from( { length: height }, (_, row) => `#${hex(data[row * 4])}${hex(data[row * 4 + 1])}${hex(data[row * 4 + 2])}`, ); }, /** Many points from one render, each the colour `pixelAt` would give – for checks that sample hundreds. */ pixels: (points: readonly { x: number; y: number }[]) => stagePixels(app, points), /** * 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))); return stagePixels( app, Array.from({ length: width }, (_, i) => ({ x: x + i, y })), ); }, /** 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, }; }, /** Drawn pictures whose revision moved on while a shader still named them (#293); freed on a later ask. */ picturesWaiting: () => textureLibrary.waitingPictures(), 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; 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), /** The device pixels of a source's field-shader picture, null while none is drawn (#170). */ fieldPixels: (objectId: string, where: 'scene' | 'game' = 'scene') => (where === 'game' ? gameView : viewport).fieldPixels(objectId), /** The pixels of the camera shader's picture, null while none is laid over the view (#299). */ cameraShaderPixels: (where: 'scene' | 'game' = 'game') => (where === 'game' ? gameView : viewport).cameraShaderPixels(), /** 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, }; }