From 7437b3bbca99bc1b1954c7464a9173210e6b8d88 Mon Sep 17 00:00:00 2001 From: Zyie <24736175+Zyie@users.noreply.github.com> Date: Wed, 11 Sep 2024 19:50:07 +0100 Subject: [PATCH] feat: add rendering panel (#43) * wip * tidy * add renderer info * nested render groups * render group view * webgpu support * undo * fix formatting * only select once * remove logs --- .../src/assets/gpuTextures/textures.ts | 23 +- packages/backend/src/pixi.ts | 29 +- .../backend/src/rendering/instructions.ts | 245 +++++++++ packages/backend/src/rendering/program.ts | 21 + packages/backend/src/rendering/readPixels.ts | 69 +++ .../backend/src/rendering/renderableData.ts | 114 +++++ packages/backend/src/rendering/rendering.ts | 471 ++++++++++++++++++ packages/backend/src/rendering/stats.ts | 34 ++ packages/backend/src/scene/overlay/overlay.ts | 1 + .../devtool-chrome/src/background/index.ts | 5 + packages/devtool-chrome/src/messageUtils.ts | 11 - packages/frontend/src/App.tsx | 5 + .../collapsible/collapsible-section.tsx | 19 +- .../src/components/smooth-charts/stat.tsx | 138 +++++ .../frontend/src/components/ui/checkbox.tsx | 2 +- .../src/components/ui/svg-texture.tsx | 26 + packages/frontend/src/globals.css | 13 + .../frontend/src/pages/assets/AssetsPanel.tsx | 2 +- .../pages/assets/gpuTextures/TextureStats.tsx | 120 ++--- .../assets/gpuTextures/TextureViewer.tsx | 12 +- .../src/pages/rendering/CanvasPanel.tsx | 58 +++ .../src/pages/rendering/InstructionsPanel.tsx | 86 ++++ .../src/pages/rendering/RenderingPanel.tsx | 76 ++- .../src/pages/rendering/RenderingStats.tsx | 47 ++ .../pages/rendering/instructions/Batch.tsx | 41 ++ .../rendering/instructions/CustomRender.tsx | 39 ++ .../pages/rendering/instructions/Filter.tsx | 49 ++ .../pages/rendering/instructions/Graphics.tsx | 36 ++ .../rendering/instructions/Instructions.tsx | 326 ++++++++++++ .../src/pages/rendering/instructions/Mask.tsx | 61 +++ .../src/pages/rendering/instructions/Mesh.tsx | 66 +++ .../instructions/NineSliceSprite.tsx | 39 ++ .../rendering/instructions/RenderGroup.tsx | 28 ++ .../rendering/instructions/TilingSprite.tsx | 49 ++ .../instructions/shared/InstructionBubble.tsx | 46 ++ .../shared/InstructionSection.tsx | 35 ++ .../instructions/shared/PropertyDisplay.tsx | 75 +++ .../rendering/instructions/shared/Shader.tsx | 64 +++ .../rendering/instructions/shared/State.tsx | 29 ++ .../rendering/instructions/shared/Texture.tsx | 51 ++ .../src/pages/rendering/instructions/test.tsx | 0 .../frontend/src/pages/rendering/rendering.ts | 110 ++++ .../src/pages/scene/stats-section/Stats.tsx | 8 +- packages/frontend/src/types.ts | 3 +- packages/frontend/tailwind.config.js | 12 + 45 files changed, 2673 insertions(+), 121 deletions(-) create mode 100644 packages/backend/src/rendering/instructions.ts create mode 100644 packages/backend/src/rendering/program.ts create mode 100644 packages/backend/src/rendering/readPixels.ts create mode 100644 packages/backend/src/rendering/renderableData.ts create mode 100644 packages/backend/src/rendering/rendering.ts create mode 100644 packages/backend/src/rendering/stats.ts create mode 100644 packages/frontend/src/components/smooth-charts/stat.tsx create mode 100644 packages/frontend/src/components/ui/svg-texture.tsx create mode 100644 packages/frontend/src/pages/rendering/CanvasPanel.tsx create mode 100644 packages/frontend/src/pages/rendering/InstructionsPanel.tsx create mode 100644 packages/frontend/src/pages/rendering/RenderingStats.tsx create mode 100644 packages/frontend/src/pages/rendering/instructions/Batch.tsx create mode 100644 packages/frontend/src/pages/rendering/instructions/CustomRender.tsx create mode 100644 packages/frontend/src/pages/rendering/instructions/Filter.tsx create mode 100644 packages/frontend/src/pages/rendering/instructions/Graphics.tsx create mode 100644 packages/frontend/src/pages/rendering/instructions/Instructions.tsx create mode 100644 packages/frontend/src/pages/rendering/instructions/Mask.tsx create mode 100644 packages/frontend/src/pages/rendering/instructions/Mesh.tsx create mode 100644 packages/frontend/src/pages/rendering/instructions/NineSliceSprite.tsx create mode 100644 packages/frontend/src/pages/rendering/instructions/RenderGroup.tsx create mode 100644 packages/frontend/src/pages/rendering/instructions/TilingSprite.tsx create mode 100644 packages/frontend/src/pages/rendering/instructions/shared/InstructionBubble.tsx create mode 100644 packages/frontend/src/pages/rendering/instructions/shared/InstructionSection.tsx create mode 100644 packages/frontend/src/pages/rendering/instructions/shared/PropertyDisplay.tsx create mode 100644 packages/frontend/src/pages/rendering/instructions/shared/Shader.tsx create mode 100644 packages/frontend/src/pages/rendering/instructions/shared/State.tsx create mode 100644 packages/frontend/src/pages/rendering/instructions/shared/Texture.tsx create mode 100644 packages/frontend/src/pages/rendering/instructions/test.tsx create mode 100644 packages/frontend/src/pages/rendering/rendering.ts diff --git a/packages/backend/src/assets/gpuTextures/textures.ts b/packages/backend/src/assets/gpuTextures/textures.ts index ba971c2..6c4ff1f 100644 --- a/packages/backend/src/assets/gpuTextures/textures.ts +++ b/packages/backend/src/assets/gpuTextures/textures.ts @@ -1,6 +1,6 @@ import type { TextureDataState } from '@devtool/frontend/pages/assets/assets'; import type { PixiDevtools } from '../../pixi'; -import type { TextureSource, GlTexture, CanvasSource } from 'pixi.js'; +import type { TextureSource, GlTexture, CanvasSource, WebGLRenderer, WebGPURenderer } from 'pixi.js'; const gpuTextureFormatSize: Record = { r8unorm: 1, @@ -84,22 +84,21 @@ export class Textures { public get() { const currentTextures = this._devtool.renderer.texture.managedTextures; - // @ts-expect-error - private property - const glTextures = this._devtool.renderer.texture['_glTextures'] as Record; + const glTextures = this.getWebTextures(); const data: TextureDataState[] = []; currentTextures.forEach((texture) => { if (!texture.resource) return; if (!this._textures.get(texture.uid)) { - const res = this._getTextureSource(texture); + const res = this.getTextureSource(texture); if (res) { this._textures.set(texture.uid, res); } } if (!this._gpuTextureSize.has(texture.uid)) { - const size = this._getMemorySize(texture, glTextures[texture.uid]); + const size = this.getMemorySize(texture, glTextures[texture.uid]); if (size) { this._gpuTextureSize.set(texture.uid, size); } @@ -129,7 +128,17 @@ export class Textures { return data; } - private _getTextureSource(texture: TextureSource) { + public getWebTextures() { + const glRenderer = this._devtool.renderer as WebGLRenderer; + const gpuRenderer = this._devtool.renderer as WebGPURenderer; + + const glTextures: Record = + glRenderer.texture['_glTextures'] || gpuRenderer.texture['_gpuSources']; + + return glTextures; + } + + public getTextureSource(texture: TextureSource) { if ( texture.resource instanceof ImageBitmap || texture.resource instanceof HTMLImageElement || @@ -175,7 +184,7 @@ export class Textures { return canvas.toDataURL('image/png'); } - private _getMemorySize(texture: TextureSource, webTexture: GlTexture | GPUTexture): number | null { + public getMemorySize(texture: TextureSource, webTexture: GlTexture | GPUTexture): number | null { if (!webTexture) return null; if (Array.isArray(texture.resource) && texture.resource[0] instanceof Uint8Array) { return texture.resource.reduce((acc, res) => acc + res.byteLength, 0); diff --git a/packages/backend/src/pixi.ts b/packages/backend/src/pixi.ts index e47cf22..09d861d 100644 --- a/packages/backend/src/pixi.ts +++ b/packages/backend/src/pixi.ts @@ -1,7 +1,7 @@ import type { DevtoolState } from '@devtool/frontend/types'; import { DevtoolMessage } from '@devtool/frontend/types'; import type { Devtools } from '@pixi/devtools'; -import type { Application, Container, Renderer } from 'pixi.js'; +import type { Application, Container, Renderer, WebGLRenderer } from 'pixi.js'; import { extensions } from './extensions/Extensions'; import { Overlay } from './scene/overlay/overlay'; import { overlayExtension } from './scene/overlay/overlayExtension'; @@ -19,6 +19,8 @@ import { loop } from './utils/loop'; import { Throttle } from './utils/throttle'; import { Textures } from './assets/gpuTextures/textures'; import type { TextureState } from '@devtool/frontend/pages/assets/assets'; +import { Rendering } from './rendering/rendering'; +import type { RenderingState } from '@devtool/frontend/pages/rendering/rendering'; /** * PixiWrapper is a class that wraps around the PixiJS library. @@ -31,7 +33,14 @@ class PixiWrapper { }; public state: Omit< DevtoolState, - 'active' | 'setActive' | 'bridge' | 'setBridge' | 'chromeProxy' | 'setChromeProxy' | keyof TextureState + | 'active' + | 'setActive' + | 'bridge' + | 'setBridge' + | 'chromeProxy' + | 'setChromeProxy' + | keyof TextureState + | keyof RenderingState > = { version: null, setVersion: function (version: DevtoolState['version']) { @@ -88,6 +97,7 @@ class PixiWrapper { public properties = new Properties(this); public overlay = new Overlay(this); public textures = new Textures(this); + public rendering = new Rendering(this); // Private properties private _devtools: Devtools | undefined; private _app: Application | undefined; @@ -230,6 +240,10 @@ class PixiWrapper { public get majorVersion() { if (this.version === '') { + if (!this.stage) { + return null; + } + // lets try and find the version const stage = this.stage; if (stage.effects != null && Array.isArray(stage.effects) && '_updateFlags' in stage) { @@ -248,6 +262,15 @@ class PixiWrapper { return this.app || (this.stage && this.renderer) ? DevtoolMessage.active : DevtoolMessage.inactive; } + public get rendererType(): 'webgl' | 'webgl2' | 'webgpu' | null { + if (!this.renderer) return null; + return this.renderer.type === 0b10 + ? 'webgpu' + : (this.renderer as WebGLRenderer).context.webGLVersion === 1 + ? 'webgl' + : 'webgl2'; + } + public inject() { // eslint-disable-next-line @typescript-eslint/no-this-alias const that = this; @@ -284,6 +307,7 @@ class PixiWrapper { // TODO: tree: 300ms, stats: 300ms, properties: 300ms, overlay: 50ms this.overlay.update(); + this.rendering.update(); if (this._updateThrottle.shouldExecute(this.settings.throttle)) { this.preupdate(); @@ -314,6 +338,7 @@ class PixiWrapper { this.stats.init(); this.tree.init(); this.textures.init(); + this.rendering.init(); this._initialized = true; } diff --git a/packages/backend/src/rendering/instructions.ts b/packages/backend/src/rendering/instructions.ts new file mode 100644 index 0000000..17c53c6 --- /dev/null +++ b/packages/backend/src/rendering/instructions.ts @@ -0,0 +1,245 @@ +import type { + BatchInstruction, + FilterInstruction, + GraphicsInstruction, + MaskInstruction, + MeshInstruction, + NineSliceInstruction, + TilingSpriteInstruction, +} from '@devtool/frontend/pages/rendering/instructions/Instructions'; +import type { + Batch, + BlurFilter, + Filter, + GlProgram, + GpuProgram, + Graphics, + Mesh, + NineSliceSprite, + FilterInstruction as PixiFilterInstruction, + Sprite, + StencilMaskInstruction, + TilingSprite, + RenderContainer, +} from 'pixi.js'; +import { getRenderableData, getStateData, getTextureData } from './renderableData'; +import { PixiDevtools } from '../pixi'; +import { getProgramSource } from './program'; + +type TextureCache = Parameters[1]; +export function getBatchInstruction(instruction: Batch, textureCache: TextureCache): BatchInstruction { + const textures: BatchInstruction['textures'] = []; + instruction.textures.textures.forEach((texture) => { + if (texture == null) return; + const tex = getTextureData(texture, textureCache); + if (tex) { + textures.push(tex); + } else { + console.log('Texture not found', texture); + } + }); + + return { + type: 'batch', + action: instruction.action, + blendMode: instruction.blendMode, + size: instruction.size, + start: instruction.start, + drawCalls: 0, + drawTextures: [], + textures, + }; +} + +export function getCustomRenderableInstruction(instruction: RenderContainer, _textureCache: TextureCache) { + return { + type: 'customRender', + action: 'custom', + renderable: { + ...getRenderableData(instruction), + }, + drawCalls: 0, + drawTextures: [], + }; +} + +export function getGraphicsInstruction(instruction: Graphics, _textureCache: TextureCache): GraphicsInstruction { + return { + type: 'graphics', + action: 'draw', + renderable: { + ...getRenderableData(instruction), + }, + drawCalls: 0, + drawTextures: [], + }; +} + +export function getNineSliceInstruction( + instruction: NineSliceSprite, + textureCache: TextureCache, +): NineSliceInstruction { + return { + type: 'nineSlice', + action: 'draw', + renderable: { + topHeight: instruction.topHeight, + bottomHeight: instruction.bottomHeight, + leftWidth: instruction.leftWidth, + rightWidth: instruction.rightWidth, + texture: instruction.texture ? getTextureData(instruction.texture._source, textureCache) : null, + originalWidth: instruction.originalWidth, + originalHeight: instruction.originalHeight, + ...getRenderableData(instruction), + }, + drawCalls: 0, + drawTextures: [], + }; +} + +export function getMaskInstruction(instruction: StencilMaskInstruction, _textureCache: TextureCache): MaskInstruction { + const mask = instruction.mask?.mask; + return { + type: instruction.renderPipeId, + action: instruction.action, + mask: mask + ? { + ...getRenderableData(mask), + } + : null, + drawCalls: 0, + drawTextures: [], + }; +} + +export function getFilterInstruction( + instruction: PixiFilterInstruction, + textureCache: TextureCache, +): FilterInstruction { + const rendererType = PixiDevtools.renderer.name === 'webgl' ? 'webgl' : 'webgpu'; + const getProgramSource = (filter: Filter, shader: 'fragment' | 'vertex'): string => { + const type = rendererType === 'webgl' ? 'glProgram' : 'gpuProgram'; + let program: GlProgram | GpuProgram; + + // if blur filter then you need to get the blurX/blurY shaders + if ((filter as BlurFilter).blurXFilter) { + program = (filter as BlurFilter).blurXFilter[type]; + } else { + program = filter[type]; + } + + if (!program) { + return ''; + } + + const source = program[shader]; + + if (!source) { + return ''; + } + + if (typeof source === 'string') { + return source; + } + + return source.source; + }; + + return { + type: 'filter', + action: instruction.action, + filter: instruction.filterEffect?.filters?.map((filter) => { + return { + type: filter.constructor.name, + padding: filter.padding, + resolution: filter.resolution, + antialias: filter.antialias, + blendMode: filter.blendMode, + program: { + fragment: getProgramSource(filter, 'fragment'), + vertex: getProgramSource(filter, 'vertex'), + }, + state: getStateData(filter._state), + }; + }), + renderables: + instruction.renderables?.map((renderable) => ({ + texture: (renderable as Sprite).texture + ? getTextureData((renderable as Sprite).texture.source, textureCache) + : null, + ...getRenderableData(renderable), + })) || ([] as FilterInstruction['renderables']), + drawCalls: 0, + drawTextures: [], + }; +} + +interface PixiMeshObjectInstruction { + renderPipeId: string; + mesh: Mesh; +} + +export function getMeshInstruction( + instruction: Mesh | PixiMeshObjectInstruction, + textureCache: TextureCache, +): MeshInstruction { + const mesh = (instruction as PixiMeshObjectInstruction)?.mesh || instruction; + const rendererType = PixiDevtools.renderer.name === 'webgl' ? 'webgl' : 'webgpu'; + let shader = mesh.shader; + const program = { + fragment: null as string | null, + vertex: null as string | null, + }; + + if (!shader) { + shader = PixiDevtools.renderer.renderPipes.mesh['_adaptor']._shader; + program.fragment = getProgramSource(shader, 'fragment', rendererType); + program.vertex = getProgramSource(shader, 'vertex', rendererType); + } else { + program.fragment = getProgramSource(shader, 'fragment', rendererType); + program.vertex = getProgramSource(shader, 'vertex', rendererType); + } + return { + type: 'mesh', + action: 'draw', + renderable: { + texture: mesh.texture ? getTextureData(mesh.texture.source, textureCache) : null, + program, + state: getStateData(mesh.state), + geometry: { + indices: Array.from(mesh.geometry.indices), + positions: Array.from(mesh.geometry.positions), + uvs: Array.from(mesh.geometry.uvs), + }, + ...getRenderableData(mesh), + }, + drawCalls: 0, + drawTextures: [], + }; +} + +export function getTileSpriteInstruction( + tilingSprite: TilingSprite, + textureCache: TextureCache, +): TilingSpriteInstruction { + return { + type: 'tilingSprite', + action: 'draw', + renderable: { + ...getRenderableData(tilingSprite), + texture: tilingSprite.texture ? getTextureData(tilingSprite.texture.source, textureCache) : null, + tilePosition: { + x: tilingSprite.tilePosition.x, + y: tilingSprite.tilePosition.y, + }, + tileScale: { + x: tilingSprite.tileScale.x, + y: tilingSprite.tileScale.y, + }, + tileRotation: tilingSprite.tileRotation, + clampMargin: tilingSprite.clampMargin, + }, + drawCalls: 0, + drawTextures: [], + }; +} diff --git a/packages/backend/src/rendering/program.ts b/packages/backend/src/rendering/program.ts new file mode 100644 index 0000000..f6a1776 --- /dev/null +++ b/packages/backend/src/rendering/program.ts @@ -0,0 +1,21 @@ +import type { Filter, TextureShader } from 'pixi.js'; + +export function getProgramSource( + filter: Filter | TextureShader, + shader: 'fragment' | 'vertex', + rendererType: 'webgl' | 'webgpu', +): string { + const type = rendererType === 'webgl' ? 'glProgram' : 'gpuProgram'; + const program = filter[type]; + const source = program[shader]; + + if (!source) { + return ''; + } + + if (typeof source === 'string') { + return source; + } + + return source.source; +} diff --git a/packages/backend/src/rendering/readPixels.ts b/packages/backend/src/rendering/readPixels.ts new file mode 100644 index 0000000..20b863f --- /dev/null +++ b/packages/backend/src/rendering/readPixels.ts @@ -0,0 +1,69 @@ +import type { GlRenderTarget, WebGLRenderer, WebGPURenderer } from 'pixi.js'; + +export function readGlPixels( + gl: WebGLRenderingContext, + renderer: WebGLRenderer, + canvasTextures: string[], + width: number, + height: number, +) { + // Create a buffer to hold the pixel data + // Uint8Array for 8-bit per channel (RGBA), adjust as needed + const pixels = new Uint8Array(width * height * 4); + + const renterTarget = renderer.renderTarget.getRenderTarget(renderer.renderTarget.renderTarget); + const glRenterTarget = renderer.renderTarget.getGpuRenderTarget(renterTarget) as GlRenderTarget; + // Bind the framebuffer you want to read from (null for default framebuffer) + gl.bindFramebuffer(gl.FRAMEBUFFER, glRenterTarget.resolveTargetFramebuffer); + + // Read the pixels + gl.readPixels( + 0, + 0, // Start reading from the bottom left of the framebuffer + width, + height, // The dimensions of the area you want to read + gl.RGBA, // Format of the pixel data + gl.UNSIGNED_BYTE, // Type of the pixel data + pixels, // The buffer to read the pixels into + ); + + // Create a 2D canvas to draw the pixels on + const canvas2d = document.createElement('canvas'); + canvas2d.width = width; + canvas2d.height = height; + const ctx = canvas2d.getContext('2d')!; + + // Create an ImageData object + const imageData = new ImageData(new Uint8ClampedArray(pixels), width, height); + + // Draw the ImageData object to the canvas + ctx.putImageData(imageData, 0, 0); + + // Convert the canvas to a data URL and set it as the src of an image element + const dataUrl = canvas2d.toDataURL('image/webp', 0.5); + canvasTextures.push(dataUrl); +} + +export function readGPUPixels(renderer: WebGPURenderer, canvasTextures: string[]) { + const webGPUCanvas = renderer.view.canvas as HTMLCanvasElement; + + const canvas = document.createElement('canvas'); + canvas.width = webGPUCanvas.width; + canvas.height = webGPUCanvas.height; + + const context = canvas.getContext('2d')!; + + context.drawImage(webGPUCanvas, 0, 0); + + // const { width, height } = webGPUCanvas; + + // context.getImageData(0, 0, width, height); + + // Convert the canvas to a data URL and set it as the src of an image element + const dataUrl = canvas.toDataURL('image/webp', 0.5); + canvasTextures.push(dataUrl); + + // const pixels = new Uint8ClampedArray(imageData.data.buffer); + + // return { pixels, width, height }; +} diff --git a/packages/backend/src/rendering/renderableData.ts b/packages/backend/src/rendering/renderableData.ts new file mode 100644 index 0000000..34192f0 --- /dev/null +++ b/packages/backend/src/rendering/renderableData.ts @@ -0,0 +1,114 @@ +import type { + RenderableData, + RenderingTextureDataState, + StateData, +} from '@devtool/frontend/pages/rendering/instructions/Instructions'; +import type { Container, Sprite, State, TextureSource } from 'pixi.js'; +import { getPixiType } from '../utils/getPixiType'; +import { PixiDevtools } from '../pixi'; + +export function getTextureData( + texture: TextureSource, + textureCache: Map, +): RenderingTextureDataState { + const devtool = PixiDevtools; + if (!textureCache.has(texture)) { + const tex = devtool.textures.getTextureSource(texture); + const glTextures = devtool.textures.getWebTextures(); + const size = devtool.textures.getMemorySize(texture, glTextures[texture.uid]); + if (tex) { + textureCache.set(texture, { + blob: tex, + name: texture.label, + gpuSize: size || 0, + pixelWidth: texture.pixelWidth, + pixelHeight: texture.pixelHeight, + }); + } + } + + return textureCache.get(texture)!; +} + +export function getStateData(filter: State): StateData { + return { + blend: filter.blend, + blendMode: filter.blendMode, + clockwiseFrontFace: filter.clockwiseFrontFace, + cullMode: filter.cullMode, + culling: filter.culling, + depthMask: filter.depthMask, + depthTest: filter.depthTest, + offsets: filter.offsets, + polygonOffset: filter.polygonOffset, + }; +} + +export function getRenderableData(container: Container | Sprite): RenderableData { + return { + class: container.constructor.name, + type: getPixiType(container), + label: container.label, + position: { + x: container.position.x, + y: container.position.y, + }, + width: container.width, + height: container.height, + scale: { + x: container.scale.x, + y: container.scale.y, + }, + anchor: (container as Sprite).anchor + ? { + x: (container as Sprite).anchor.x, + y: (container as Sprite).anchor.y, + } + : null, + rotation: container.rotation, + angle: container.angle, + pivot: { + x: container.pivot.x, + y: container.pivot.y, + }, + skew: { + x: container.skew.x, + y: container.skew.y, + }, + visible: container.visible, + renderable: container.renderable, + alpha: container.alpha, + tint: container.tint, + blendMode: container.blendMode, + roundPixels: (container as Sprite).roundPixels ?? false, + filterArea: container.filterArea + ? { + x: container.filterArea.x, + y: container.filterArea.y, + width: container.filterArea.width, + height: container.filterArea.height, + } + : null, + isRenderGroup: container.isRenderGroup, + sortableChildren: container.sortableChildren, + zIndex: container.zIndex, + boundsArea: container.boundsArea + ? { + x: container.boundsArea.x, + y: container.boundsArea.y, + width: container.boundsArea.width, + height: container.boundsArea.height, + } + : null, + cullable: container.cullable ?? false, + cullArea: container.cullArea + ? { + x: container.cullArea.x, + y: container.cullArea.y, + width: container.cullArea.width, + height: container.cullArea.height, + } + : null, + cullableChildren: container.cullableChildren ?? false, + }; +} diff --git a/packages/backend/src/rendering/rendering.ts b/packages/backend/src/rendering/rendering.ts new file mode 100644 index 0000000..43dbcde --- /dev/null +++ b/packages/backend/src/rendering/rendering.ts @@ -0,0 +1,471 @@ +import type { + BaseInstruction, + BatchInstruction, + FilterInstruction, + GraphicsInstruction, + MaskInstruction, + MeshInstruction, + NineSliceInstruction, + RenderingTextureDataState, + TilingSpriteInstruction, +} from '@devtool/frontend/pages/rendering/instructions/Instructions'; +import type { FrameCaptureData, RenderingState } from '@devtool/frontend/pages/rendering/rendering'; +import type { + Batch, + CanvasSource, + Container, + GlGeometrySystem, + Graphics, + InstructionPipe, + Mesh, + NineSliceSprite, + FilterInstruction as PixiFilterInstruction, + RenderContainer, + Renderer, + RenderGroup, + StencilMaskInstruction, + TextureSource, + TilingSprite, + WebGLRenderer, + WebGPURenderer, +} from 'pixi.js'; +import type { PixiDevtools } from '../pixi'; +import { getPixiType } from '../utils/getPixiType'; +import { loop } from '../utils/loop'; +import { + getBatchInstruction, + getCustomRenderableInstruction, + getFilterInstruction, + getGraphicsInstruction, + getMaskInstruction, + getMeshInstruction, + getNineSliceInstruction, + getTileSpriteInstruction, +} from './instructions'; +import { readGlPixels } from './readPixels'; +import { Stats } from './stats'; + +interface PixiMeshObjectInstruction { + renderPipeId: string; + mesh: Mesh; +} + +export class Rendering { + private _devtool: typeof PixiDevtools; + private _textureCache: Map = new Map(); + + private _glDrawFn!: GlGeometrySystem['draw']; + private _drawOrder: { pipe: string; drawCalls: number }[] = []; + private _pipeExecuteFn: Map['execute']> = new Map(); + + private _capturing = false; + private _currentPipe: string | null = null; + private _withScreenshot = false; + private _canvasTextures: string[] = []; + + private _rebuilt = false; + + private stats = new Stats(); + + constructor(devtool: typeof PixiDevtools) { + this._devtool = devtool; + } + + public init() { + this._textureCache.clear(); + this.stats.reset(); + + this._capturing = false; + this._currentPipe = null; + this._canvasTextures = []; + + // override the draw function to keep track of draw calls + const renderer = this._devtool.renderer; + + // override the batcher buildStart function to keep track of rebuild frequency + const batcherBuildStartFn = renderer.renderPipes.batch.buildStart; + renderer.renderPipes.batch.buildStart = (...args) => { + const res = batcherBuildStartFn.apply(renderer.renderPipes.batch, args); + this._rebuilt = true; + + return res; + }; + + if (renderer.type === 0b10) { + const gpuRenderer = renderer as WebGPURenderer; + const drawOverride = (id: 'draw' | 'drawIndexed') => { + const originalDraw = encoder.renderPassEncoder[id]; + encoder.renderPassEncoder[id] = (...args) => { + if (this._capturing) { + if (this._currentPipe) { + const pipe = this._drawOrder.at(-1); + if (pipe) { + pipe.drawCalls++; + } + } + } + + const res = originalDraw.apply(encoder.renderPassEncoder, args as any); + this.stats.drawCalls++; + + if (!this._capturing) return res; + if (!this._withScreenshot) return res; + + // readGPUPixels(gpuRenderer, this._canvasTextures); + return res; + }; + }; + const encoder = gpuRenderer.encoder; + const originalBeginRenderPass = encoder.beginRenderPass; + encoder.beginRenderPass = (...args) => { + const res = originalBeginRenderPass.apply(encoder, args); + + drawOverride('draw'); + drawOverride('drawIndexed'); + + return res; + }; + const originalRestoreRenderPass = encoder.restoreRenderPass; + encoder.restoreRenderPass = (...args) => { + const res = originalRestoreRenderPass.apply(encoder, args); + + drawOverride('draw'); + drawOverride('drawIndexed'); + + return res; + }; + return; + } + + const glRenderer = renderer as WebGLRenderer; + + this._glDrawFn = glRenderer.geometry.draw; + const gl = glRenderer.gl; + glRenderer.geometry.draw = (...args) => { + if (this._capturing) { + if (this._currentPipe) { + const pipe = this._drawOrder.at(-1); + if (pipe) { + pipe.drawCalls++; + } + } + } + + const res = this._glDrawFn.apply(glRenderer.geometry, args); + this.stats.drawCalls++; + + if (!this._capturing) return res; + if (!this._withScreenshot) return res; + + const width = gl.drawingBufferWidth; + const height = gl.drawingBufferHeight; + readGlPixels(gl, glRenderer, this._canvasTextures, width, height); + return res; + }; + } + public update() { + this.stats.drawCalls = 0; + this.stats.update(); + } + public complete() {} + + public captureCanvasData(): RenderingState['canvasData'] { + const renderer = this._devtool.renderer; + // const webgpuRenderer = renderer as WebGPURenderer; + const webglRenderer = renderer as WebGLRenderer; + const canvas = renderer.view.canvas as HTMLCanvasElement; + + let powerPreference: 'default' | 'high-performance' | 'low-power' = 'default'; + let failIfMajorPerformanceCaveat: string | undefined = undefined; + let preserveDrawingBuffer: string = ''; + let premultipliedAlpha: string = ''; + if (renderer.type !== 0b10) { + const contextAttributes = webglRenderer.gl.getContextAttributes()!; + powerPreference = contextAttributes.powerPreference as 'default' | 'high-performance' | 'low-power'; + failIfMajorPerformanceCaveat = contextAttributes.failIfMajorPerformanceCaveat?.toString(); + preserveDrawingBuffer = contextAttributes.preserveDrawingBuffer?.toString() ?? ''; + premultipliedAlpha = contextAttributes.premultipliedAlpha?.toString() ?? ''; + } + + return { + type: this._devtool.rendererType!, + width: canvas.width, + height: canvas.height, + clientWidth: canvas.clientWidth, + clientHeight: canvas.clientHeight, + browserAgent: navigator.userAgent, + + background: renderer.background.color.toHex(), + backgroundAlpha: renderer.background.alpha.toString(), + antialias: renderer.view.antialias.toString(), + autoDensity: (renderer.view.texture.source as CanvasSource).autoDensity.toString(), + clearBeforeRender: renderer.background.clearBeforeRender.toString(), + depth: renderer.view.renderTarget.depth.toString(), + powerPreference, + preserveDrawingBuffer, + premultipliedAlpha, + resolution: renderer.resolution.toString(), + roundPixels: renderer.roundPixels.toString(), + failIfMajorPerformanceCaveat, + + // @ts-expect-error - private properties + renderableGCActive: renderer.renderableGC?.enabled.toString(), + // @ts-expect-error - private properties + renderableGCFrequency: renderer.renderableGC?._frequency.toString(), + // @ts-expect-error - private properties + renderableGCMaxUnusedTime: renderer.renderableGC?.maxUnusedTime.toString(), + textureGCAMaxIdle: renderer.textureGC.maxIdle.toString(), + textureGCActive: renderer.textureGC.active.toString(), + textureGCCheckCountMax: renderer.textureGC.checkCountMax.toString(), + }; + } + + public captureRenderingData(): RenderingState['renderingData'] { + const rebuildFrequency = this._rebuilt ? 1 : 0; + this._rebuilt = false; + return { + drawCalls: this.stats.drawCalls, + fps: Number(this.stats.fps.toFixed(0)), + rebuildFrequency, + }; + } + + public capture(withScreenshot: boolean): FrameCaptureData { + this._textureCache.clear(); + const renderer = this._devtool.renderer; + const lastObjectRendered = renderer.lastObjectRendered; + const instructionSet = lastObjectRendered.renderGroup.instructionSet; + const frameCaptureData: FrameCaptureData = { + instructions: [], + drawCalls: 0, + totals: { + containers: 0, + graphics: 0, + meshes: 0, + sprites: 0, + texts: 0, + tilingSprites: 0, + nineSliceSprites: 0, + filters: 0, + masks: 0, + }, + renderTime: this._getRenderTime(renderer), + }; + + const canvasTextures = this._capture(renderer, withScreenshot); + const sceneData = this._getSceneData(lastObjectRendered); + const instructionData = this._getInstructionsData(instructionSet, canvasTextures, this._drawOrder); + frameCaptureData.drawCalls = canvasTextures.length; + frameCaptureData.instructions = instructionData; + frameCaptureData.totals = sceneData; + + return frameCaptureData; + } + + private _capture(renderer: Renderer, withScreenshot: boolean) { + this._drawOrder = []; + this._pipeExecuteFn.clear(); + + this._capturing = true; + this._withScreenshot = withScreenshot; + this._currentPipe = null; + this._canvasTextures = []; + + const instructionSet = renderer.lastObjectRendered.renderGroup.instructionSet; + const renderPipes = instructionSet.renderPipes as Record>; + + // override each execute function to keep track of draw calls + Object.keys(renderPipes).forEach((key) => { + const pipe = renderPipes[key]; + if (!pipe.execute) return; + + this._pipeExecuteFn.set(key, pipe.execute); + pipe.execute = (...args) => { + this._currentPipe = key; + this._drawOrder.push({ pipe: key, drawCalls: 0 }); + const res = this._pipeExecuteFn.get(key)!.apply(pipe, args); + return res; + }; + }); + + renderer.render(renderer.lastObjectRendered); + + this._postCapture(renderer); + + return this._canvasTextures; + } + + private _postCapture(renderer: Renderer) { + const renderPipes = renderer.lastObjectRendered.renderGroup.instructionSet.renderPipes as Record< + string, + InstructionPipe + >; + Object.keys(renderPipes).forEach((key) => { + const pipe = renderPipes[key]; + if (!pipe.execute) return; + pipe.execute = this._pipeExecuteFn.get(key)!; + }); + + this._capturing = false; + } + + private _getRenderTime(renderer: Renderer) { + const now = performance.now(); + renderer.render(renderer.lastObjectRendered); + return performance.now() - now; + } + + private _getSceneData(lastObjectRendered: Container) { + const data: FrameCaptureData['totals'] = { + containers: 0, + graphics: 0, + meshes: 0, + sprites: 0, + texts: 0, + tilingSprites: 0, + nineSliceSprites: 0, + filters: 0, + masks: 0, + }; + loop({ + container: lastObjectRendered, + loop(container) { + const type = getPixiType(container); + + switch (type) { + case 'Container': + data.containers++; + break; + case 'Graphics': + data.graphics++; + break; + case 'Mesh': + data.meshes++; + break; + case 'Sprite': + case 'AnimatedSprite': + data.sprites++; + break; + case 'Text': + case 'BitmapText': + case 'HTMLText': + data.texts++; + break; + case 'TilingSprite': + data.tilingSprites++; + break; + case 'NineSliceSprite': + data.nineSliceSprites++; + break; + } + + if (container.effects) { + data.filters += Array.isArray(container.effects) ? container.effects.length : 1; + } + + if (container.mask) { + data.masks++; + } + }, + }); + + return data; + } + + private _getInstructionsData( + instructionSet: RenderGroup['instructionSet'], + canvasTextures: string[], + drawOrder: { pipe: string; drawCalls: number }[], + drawTotal = 0, + instructionCount = 0, + ) { + const instructionData: FrameCaptureData['instructions'] = []; + + instructionData.push({ + type: 'Render Group', + action: 'start', + drawCalls: 0, + drawTextures: [], + }); + + for (let i = 0; i < instructionSet.instructionSize; i++) { + const instruction = instructionSet.instructions[i]; + let data: + | BaseInstruction + | BatchInstruction + | FilterInstruction + | GraphicsInstruction + | MaskInstruction + | MeshInstruction + | TilingSpriteInstruction + | NineSliceInstruction = null as any; + + switch (instruction.renderPipeId) { + case 'batch': + data = getBatchInstruction(instruction as Batch, this._textureCache); + break; + case 'filter': + data = getFilterInstruction(instruction as PixiFilterInstruction, this._textureCache); + break; + case 'stencilMask': + case 'alphaMask': + case 'colorMask': + data = getMaskInstruction(instruction as StencilMaskInstruction, this._textureCache); + break; + case 'tilingSprite': + data = getTileSpriteInstruction(instruction as TilingSprite, this._textureCache); + break; + case 'mesh': + data = getMeshInstruction(instruction as Mesh | PixiMeshObjectInstruction, this._textureCache); + break; + case 'graphics': + data = getGraphicsInstruction(instruction as Graphics, this._textureCache); + break; + case 'nineSliceSprite': + data = getNineSliceInstruction(instruction as unknown as NineSliceSprite, this._textureCache); + break; + case 'customRender': + data = getCustomRenderableInstruction(instruction as RenderContainer, this._textureCache); + break; + case 'renderGroup': + { + const res = this._getInstructionsData( + (instruction as RenderGroup).instructionSet, + canvasTextures, + drawOrder, + drawTotal, + instructionCount + 1, + ); + instructionData.push(...res); + } + break; + default: + data = { + type: instruction.renderPipeId as any, + action: instruction.action ?? 'unknown', + } as BaseInstruction; + break; + } + + if (!data) continue; + + const drawOrderData = drawOrder[instructionCount]; + data.drawCalls = drawOrderData.drawCalls; + drawTotal += data.drawCalls; + data.drawTextures = canvasTextures.slice(drawTotal - data.drawCalls, drawTotal); + + instructionCount++; + + instructionData.push(data); + } + + instructionData.push({ + type: 'Render Group', + action: 'end', + drawCalls: 0, + drawTextures: [], + }); + + return instructionData; + } +} diff --git a/packages/backend/src/rendering/stats.ts b/packages/backend/src/rendering/stats.ts new file mode 100644 index 0000000..4ed4612 --- /dev/null +++ b/packages/backend/src/rendering/stats.ts @@ -0,0 +1,34 @@ +export class Stats { + private frames: number = 0; + private prevTime: number = 0; + public fps: number = 0; + public memory: number = 0; + public maxMemory: number = 0; + public drawCalls: number = 0; + + public update(): void { + this.frames++; + + const time = (performance || Date).now(); + + if (time >= this.prevTime + 1000) { + this.fps = (this.frames * 1000) / (time - this.prevTime); + this.prevTime = time; + this.frames = 0; + + // @ts-expect-error it does exist in chrome + const memory = performance.memory; + this.memory = memory.usedJSHeapSize / 1048576; + this.maxMemory = memory.jsHeapSizeLimit / 1048576; + } + } + + public reset(): void { + this.frames = 0; + this.prevTime = 0; + this.fps = 0; + this.memory = 0; + this.maxMemory = 0; + this.drawCalls = 0; + } +} diff --git a/packages/backend/src/scene/overlay/overlay.ts b/packages/backend/src/scene/overlay/overlay.ts index c941096..43a5b61 100644 --- a/packages/backend/src/scene/overlay/overlay.ts +++ b/packages/backend/src/scene/overlay/overlay.ts @@ -247,6 +247,7 @@ export class Overlay { if (hit) { this._devtool.tree.setSelectedFromNode(hit); + this.enablePicker(false); } } } diff --git a/packages/devtool-chrome/src/background/index.ts b/packages/devtool-chrome/src/background/index.ts index 719b9b5..8821b57 100644 --- a/packages/devtool-chrome/src/background/index.ts +++ b/packages/devtool-chrome/src/background/index.ts @@ -99,4 +99,9 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { }); } } + if (tab.active && changeInfo.status === 'complete') { + // TODO: we can send a message to the devtools to reload the page + console.log(`Tab ${tabId} has reloaded.`); + // You can perform additional actions here + } }); diff --git a/packages/devtool-chrome/src/messageUtils.ts b/packages/devtool-chrome/src/messageUtils.ts index c43ac8d..a379261 100644 --- a/packages/devtool-chrome/src/messageUtils.ts +++ b/packages/devtool-chrome/src/messageUtils.ts @@ -1,16 +1,5 @@ import type { DevtoolMessage } from '@devtool/frontend/types'; -export enum MessageType { - Inactive = 'inactive', - Active = 'active', - PopupOpened = 'popup-opened', - PixiDetected = 'pixi-detected', - - InjectSettingsChanged = 'inject-settings-changed', - - StateUpdate = 'pixi-state-update', -} - /** * Standard message format * @param method - The message type diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 578365c..712e02e 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -15,6 +15,7 @@ import { ScenePanel } from './pages/scene/ScenePanel'; import { sceneStateSlice } from './pages/scene/state'; import type { DevtoolState } from './types'; import { textureStateSlice } from './pages/assets/assets'; +import { renderingStateSlice } from './pages/rendering/rendering'; const tabComponents = { Scene: , @@ -53,6 +54,7 @@ export const useDevtoolStore = createSelectors( ...sceneStateSlice(set), ...textureStateSlice(set), + ...renderingStateSlice(set), })), ); @@ -90,15 +92,18 @@ const App: React.FC = ({ bridge, chromeProxy }) => { case 'pixi-active': { setActive(true); + console.log('PixiJS detected'); } break; case 'pixi-pulse': { bridge('window.__PIXI_DEVTOOLS_WRAPPER__.inject()'); + console.log('Pulsing'); break; } case 'pixi-inactive': { setActive(false); + console.log('PixiJS not detected'); } break; case 'pixi-state-update': diff --git a/packages/frontend/src/components/collapsible/collapsible-section.tsx b/packages/frontend/src/components/collapsible/collapsible-section.tsx index bb0cf83..733b43b 100644 --- a/packages/frontend/src/components/collapsible/collapsible-section.tsx +++ b/packages/frontend/src/components/collapsible/collapsible-section.tsx @@ -1,12 +1,23 @@ import { cn } from '../../lib/utils'; import { useState } from 'react'; import { FaAngleDown, FaCopy as CopyIcon } from 'react-icons/fa6'; +import { useLocalStorage } from '../../lib/localStorage'; + +export const SaveCollapsibleSection: React.FC = ({ + storageKey, + ...props +}) => { + const [defaultCollapsed, setDefaultCollapsed] = useLocalStorage(storageKey, props.defaultCollapsed); + + return ; +}; interface CollapsibleSectionProps extends React.HTMLAttributes { children: React.ReactNode; title: string; onCopy?: () => void; defaultCollapsed?: boolean; + onCollapse?: (collapsed: boolean) => void; } export const CollapsibleSection = ({ className, @@ -14,6 +25,7 @@ export const CollapsibleSection = ({ title, onCopy, defaultCollapsed, + onCollapse, }: CollapsibleSectionProps) => { const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false); @@ -21,10 +33,13 @@ export const CollapsibleSection = ({ <>
setCollapsed(!collapsed)} + onClick={() => { + setCollapsed(!collapsed); + onCollapse && onCollapse(!collapsed); + }} >
{onCopy && ( diff --git a/packages/frontend/src/components/smooth-charts/stat.tsx b/packages/frontend/src/components/smooth-charts/stat.tsx new file mode 100644 index 0000000..559839f --- /dev/null +++ b/packages/frontend/src/components/smooth-charts/stat.tsx @@ -0,0 +1,138 @@ +class Panel { + public canvas: HTMLCanvasElement; + public name: string; + protected fg: CanvasFillStrokeStyles['fillStyle']; + protected bg: CanvasFillStrokeStyles['fillStyle']; + protected lineColor: CanvasFillStrokeStyles['fillStyle']; + protected textColor: CanvasFillStrokeStyles['fillStyle']; + protected min: number; + protected max: number; + protected context: CanvasRenderingContext2D; + protected PR = 2; + protected WIDTH = 250; + protected HEIGHT = 55; + protected TEXT_X = 0; + protected TEXT_Y = 0; + protected GRAPH_X = 0; + protected GRAPH_Y = 25; + protected GRAPH_WIDTH = 250; + protected GRAPH_HEIGHT = 30; + + private _maxValue!: number; + + constructor( + canvas: HTMLCanvasElement, + name: string, + fg: CanvasFillStrokeStyles['fillStyle'], + bg: CanvasFillStrokeStyles['fillStyle'], + lineColor: CanvasFillStrokeStyles['fillStyle'], + textColor: CanvasFillStrokeStyles['fillStyle'] = 'white', + ) { + this.name = name; + this.fg = fg; + this.bg = bg; + this.lineColor = lineColor; + this.textColor = textColor; + + this.min = Infinity; + this.max = 0; + + this.canvas = canvas; + this.canvas.width = this.WIDTH; + this.canvas.height = this.HEIGHT; + this.canvas.style.cssText = `width:${this.WIDTH}px;height:${this.HEIGHT}px`; + + this.context = this.canvas.getContext('2d')!; + this.context.textBaseline = 'top'; + + this.context.fillStyle = bg; + this.context.fillRect(0, 0, this.WIDTH, this.HEIGHT); + } + + public update(value: number) { + this.min = Math.min(this.min, value); + this.max = Math.max(this.max, value); + this._maxValue = Math.max(this.max, this._maxValue || 0); + + this.context.fillStyle = this.bg; + this.context.fillRect(0, 0, this.WIDTH, this.GRAPH_Y); + this.context.fillStyle = this.textColor; + this.context.font = `0.75rem cascadia code,Menlo,Monaco,'Courier New',monospace`; + this.context.fillText(`${this.name} (${Math.round(value)})`, this.TEXT_X, this.TEXT_Y); + const width1 = this.context.measureText(`max: ${Math.round(this.max)}`).width; + const width2 = this.context.measureText(`min: ${Math.round(this.min)}`).width; + const largestWidth = Math.max(width1, width2); + const x = this.WIDTH - largestWidth; + this.context.fillText(`max: ${Math.round(this.max)}`, x, this.TEXT_Y); + this.context.fillText(`min: ${Math.round(this.min)}`, x, this.TEXT_Y + 12); + + this.context.fillStyle = this.fg; + this.context.drawImage( + this.canvas, + this.GRAPH_X + this.PR, + this.GRAPH_Y, + this.GRAPH_WIDTH - this.PR, + this.GRAPH_HEIGHT, + this.GRAPH_X, + this.GRAPH_Y, + this.GRAPH_WIDTH - this.PR, + this.GRAPH_HEIGHT, + ); + + const lineHeight = 1; + + if (value !== 0) { + this.context.fillStyle = this.lineColor; + this.context.fillRect(this.GRAPH_X + this.GRAPH_WIDTH - this.PR, this.GRAPH_Y, this.PR, this.GRAPH_HEIGHT); + } + + let downHeight = Math.round((1 - value / this._maxValue) * this.GRAPH_HEIGHT) + lineHeight; + if (value === 0) { + downHeight = this.GRAPH_HEIGHT - lineHeight; + } + this.context.fillStyle = this.bg; + this.context.fillRect(this.GRAPH_X + this.GRAPH_WIDTH - this.PR, this.GRAPH_Y, this.PR, downHeight); + this.context.fillStyle = this.fg; + this.context.fillRect(this.GRAPH_X + this.GRAPH_WIDTH - this.PR, this.GRAPH_Y + downHeight, this.PR, lineHeight); + } +} + +import React, { memo, useEffect, useRef } from 'react'; +import { useInterval } from '../../lib/interval'; + +interface CanvasProps { + title: string; + bgColor: string; + fgColor: string; + lineColor: string; + textColor: string; + value: number; +} +export const CanvasStatComponent: React.FC = memo( + ({ title, bgColor, fgColor, lineColor, textColor, value }) => { + const canvasRef = useRef(null); + const panelRef = useRef(null); + + useEffect(() => { + if (canvasRef.current && !panelRef.current) { + // Initialize the Panel class with the canvas element + panelRef.current = new Panel(canvasRef.current, title, fgColor, bgColor, lineColor, textColor); + } + }, []); + useEffect(() => { + panelRef.current = new Panel(canvasRef.current!, title, fgColor, bgColor, lineColor, textColor); + }, [bgColor, fgColor, lineColor, title, textColor]); + + useInterval(() => { + if (panelRef.current) { + panelRef.current.update(value); + } + }, 100); + + return ( +
+ +
+ ); + }, +); diff --git a/packages/frontend/src/components/ui/checkbox.tsx b/packages/frontend/src/components/ui/checkbox.tsx index 00d1c56..6c53012 100644 --- a/packages/frontend/src/components/ui/checkbox.tsx +++ b/packages/frontend/src/components/ui/checkbox.tsx @@ -11,7 +11,7 @@ const Checkbox = React.forwardRef< = ({ color }) => { + const { theme } = useTheme(); + + const color1 = theme === 'dark' ? 'hsla(0,0%,15%,0.75)' : 'hsla(0,0%,94%,0.75)'; + + return ( + + + + + + + + + + + + ); +}; diff --git a/packages/frontend/src/globals.css b/packages/frontend/src/globals.css index fdd37ab..80a6693 100644 --- a/packages/frontend/src/globals.css +++ b/packages/frontend/src/globals.css @@ -93,6 +93,19 @@ h6 { --scroll: 0, 0%, 81%; --scrollHover: 0, 0%, 45%; + + --instruction-red: 12, 76%, 61%; + --instruction-orange: 27, 87%, 67%; + --instruction-yellow: 43, 74%, 66%; + --instruction-dark-blue: 198, 63%, 38%; + --instruction-cyan: 173, 58%, 39%; + + --instruction-pink: 322, 55%, 59%; + --instruction-purple: 261, 57%, 58%; + --instruction-blue: 207, 64%, 54%; + --instruction-lime: 74, 59%, 55%; + --instruction-tan: 29, 41%, 33%; + } .dark { diff --git a/packages/frontend/src/pages/assets/AssetsPanel.tsx b/packages/frontend/src/pages/assets/AssetsPanel.tsx index 9b70b8f..1576d39 100644 --- a/packages/frontend/src/pages/assets/AssetsPanel.tsx +++ b/packages/frontend/src/pages/assets/AssetsPanel.tsx @@ -24,7 +24,7 @@ export const AssetsPanel = () => { if (Number(version) < 8) { return (
-
+
This panel is only available for PixiJS 8 and above
diff --git a/packages/frontend/src/pages/assets/gpuTextures/TextureStats.tsx b/packages/frontend/src/pages/assets/gpuTextures/TextureStats.tsx index 769efda..4dfc927 100644 --- a/packages/frontend/src/pages/assets/gpuTextures/TextureStats.tsx +++ b/packages/frontend/src/pages/assets/gpuTextures/TextureStats.tsx @@ -1,105 +1,51 @@ +import { useEffect, useState } from 'react'; import { useDevtoolStore } from '../../../App'; import { CollapsibleSection } from '../../../components/collapsible/collapsible-section'; -import SmoothieComponent, { TimeSeries } from '../../../components/smooth-charts/Smoothie'; +import { CanvasStatComponent } from '../../../components/smooth-charts/stat'; import { useTheme } from '../../../components/theme-provider'; -import { useEffect, useRef, useState } from 'react'; -interface StatData { - timeSeries: TimeSeries; - name: string; - color: string; -} - -const defaultSmoothieOptions = { - width: 250, - grid: { - strokeStyle: 'transparent', - fillStyle: 'transparent', - }, - labels: { - fillStyle: 'rgb(255, 255, 255)', - precision: 0, - }, - millisPerPixel: 60, - height: 30, -}; - -const updateGraph = (timeSeries: TimeSeries, numContainers: number) => { - timeSeries.append(new Date().getTime(), numContainers); -}; - -const currentStats = [] as StatData[]; -currentStats.push({ - timeSeries: new TimeSeries(), - name: 'GPU Memory (MB)', - color: '#E72264', -}); -currentStats.push({ - timeSeries: new TimeSeries(), - name: 'Total Textures', - color: '#E72264', -}); -currentStats.push({ - timeSeries: new TimeSeries(), - name: 'Textures loaded on GPU', - color: '#E72264', -}); -const stats = { - 'GPU Memory (MB)': 0, - 'Total Textures': 0, - 'Textures loaded on GPU': 0, -}; export const TextureStats: React.FC = () => { const { theme } = useTheme(); - const [savedStats] = useState(currentStats); + const [stats, setStats] = useState>({ + 'GPU Memory (MB)': 0, + 'Total Textures': 0, + 'Textures on GPU': 0, + }); const textures = useDevtoolStore.use.textures(); - const animationRef = useRef(); - - defaultSmoothieOptions.labels.fillStyle = theme === 'dark' ? 'rgb(255, 255, 255)' : 'rgb(0, 0, 0)'; - const seriesFillStyle = theme === 'dark' ? 'rgba(231, 34, 100, 0.2)' : 'rgba(231, 34, 100, 0.8)'; useEffect(() => { - // GPU Memory (MB) const totalMemory = textures.reduce((acc, texture) => acc + texture.gpuSize, 0); - // total textures const totalTextures = textures.length; - // Textures loaded on GPU const totalLoadedTextures = textures.filter((texture) => texture.isLoaded).length; - stats['GPU Memory (MB)'] = Number((totalMemory / 1024 / 1024).toFixed(2)); - stats['Total Textures'] = totalTextures; - stats['Textures loaded on GPU'] = totalLoadedTextures; - - animationRef.current && cancelAnimationFrame(animationRef.current); - - let savedTime = 0; - const loop = () => { - const currentTime = Date.now(); - - if (currentTime - 250 > savedTime) { - savedTime = currentTime; - savedStats.forEach((item) => { - updateGraph(item.timeSeries, stats![item.name as keyof typeof stats]); - }); - } - - animationRef.current = requestAnimationFrame(loop); - }; - - // Start the loop - animationRef.current = requestAnimationFrame(loop); - - // Clean up on unmount - return () => { - cancelAnimationFrame(animationRef.current!); - }; - }, [savedStats, textures]); + setStats({ + 'GPU Memory (MB)': Number((totalMemory / 1024 / 1024).toFixed(2)), + 'Total Textures': totalTextures, + 'Textures on GPU': totalLoadedTextures, + }); + }, [textures]); return ( -
-
- {currentStats.map((item) => { +
+
+ {stats && + Object.keys(stats!).map((key) => { + const item = stats![key as keyof typeof stats]; + return ( +
+ +
+ ); + })} + {/* {currentStats.map((item) => { return (
@@ -120,7 +66,7 @@ export const TextureStats: React.FC = () => { />
); - })} + })} */}
diff --git a/packages/frontend/src/pages/assets/gpuTextures/TextureViewer.tsx b/packages/frontend/src/pages/assets/gpuTextures/TextureViewer.tsx index 2252527..e04b9e1 100644 --- a/packages/frontend/src/pages/assets/gpuTextures/TextureViewer.tsx +++ b/packages/frontend/src/pages/assets/gpuTextures/TextureViewer.tsx @@ -2,10 +2,11 @@ import React, { memo, useEffect, useState } from 'react'; import transparentLight from '../../../assets/transparent-light.svg'; import transparent from '../../../assets/transparent.svg'; import { useTheme } from '../../../components/theme-provider'; -import { formatNumber } from '../../../lib/utils'; +import { cn, formatNumber } from '../../../lib/utils'; import type { TextureDataState } from '../assets'; -interface TextureViewerProps extends TextureDataState { +interface TextureViewerProps + extends Pick { onClick?: () => void; selected?: boolean; } @@ -36,7 +37,10 @@ export const TextureViewer: React.FC = memo( const border = selected ? 'border-secondary' : 'border-border'; return (
@@ -44,7 +48,7 @@ export const TextureViewer: React.FC = memo(
content
-
+
{sanitizedName}
Size: {formatNumber(pixelWidth, 1)} x {formatNumber(pixelHeight, 1)} diff --git a/packages/frontend/src/pages/rendering/CanvasPanel.tsx b/packages/frontend/src/pages/rendering/CanvasPanel.tsx new file mode 100644 index 0000000..eefec31 --- /dev/null +++ b/packages/frontend/src/pages/rendering/CanvasPanel.tsx @@ -0,0 +1,58 @@ +import { useDevtoolStore } from '../../App'; +import { SaveCollapsibleSection } from '../../components/collapsible/collapsible-section'; +import { propertyMap } from '../../components/properties/propertyTypes'; +import { useInterval } from '../../lib/interval'; +import { isDifferent } from '../../lib/utils'; +import { PropertyEntries } from './instructions/shared/PropertyDisplay'; +import type { CanvasData } from './rendering'; + +export const CanvasPanel = () => { + const bridge = useDevtoolStore.use.bridge(); + const canvasData = useDevtoolStore.use.canvasData(); + const setCanvasData = useDevtoolStore.use.setCanvasData(); + + useInterval(async () => { + const res = await bridge!('window.__PIXI_DEVTOOLS_WRAPPER__.rendering.captureCanvasData()'); + if (isDifferent(res, canvasData)) { + setCanvasData(res as CanvasData); + } + }, 2500); + + if (!canvasData) { + return null; + } + + return ( + +
+
+ + {/*
width: {canvasData?.width}
+
height: {canvasData?.height}
+
clientWidth: {canvasData?.clientWidth}
+
clientHeight: {canvasData?.clientHeight}
+
browserAgent: {canvasData?.browserAgent}
+
alpha: {canvasData?.context.alpha}
+
antialias: {canvasData?.context.antialias}
+
depth: {canvasData?.context.depth}
+
preserveDrawingBuffer: {canvasData?.context.preserveDrawingBuffer}
+
powerPreference: {canvasData?.context.powerPreference}
+
premultipliedAlpha: {canvasData?.context.premultipliedAlpha}
+
autoDensity: {canvasData?.renderer?.autoDensity}
+
background: {canvasData?.renderer?.background}
+
clearBeforeRender: {canvasData?.renderer?.clearBeforeRender}
+
{canvasData?.renderer?.failIfMajorPerformanceCaveat}
+
renderableGCActive: {canvasData?.renderer?.renderableGCActive}
+
renderableGCFrequency: {canvasData?.renderer?.renderableGCFrequency}
+
renderableGCMaxUnusedTime: {canvasData?.renderer?.renderableGCMaxUnusedTime}
+
resolution: {canvasData?.renderer?.resolution}
+
roundPixels: {canvasData?.renderer?.roundPixels}
+
textureGCAMaxIdle: {canvasData?.renderer?.textureGCAMaxIdle}
+
textureGCActive: {canvasData?.renderer?.textureGCActive}
+
textureGCCheckCountMax: {canvasData?.renderer?.textureGCCheckCountMax}
+
type: {canvasData?.renderer?.type}
*/} +
+
+
+ ); +}; diff --git a/packages/frontend/src/pages/rendering/InstructionsPanel.tsx b/packages/frontend/src/pages/rendering/InstructionsPanel.tsx new file mode 100644 index 0000000..c6d1d9a --- /dev/null +++ b/packages/frontend/src/pages/rendering/InstructionsPanel.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react'; +import { FaCircleDot as CaptureIcon } from 'react-icons/fa6'; +import { useDevtoolStore } from '../../App'; +import { CollapsibleSection } from '../../components/collapsible/collapsible-section'; +import { Button } from '../../components/ui/button'; +import { Checkbox } from '../../components/ui/checkbox'; +import { Separator } from '../../components/ui/separator'; +import { useInterval } from '../../lib/interval'; +import { Instructions } from './instructions/Instructions'; +import type { RenderingState } from './rendering'; + +export const InstructionsPanel = () => { + const [loading, setLoading] = useState(false); + const bridge = useDevtoolStore.use.bridge(); + const captureWithScreenshot = useDevtoolStore.use.captureWithScreenshot(); + const setCaptureWithScreenshot = useDevtoolStore.use.setCaptureWithScreenshot(); + const setFrameCaptureData = useDevtoolStore.use.setFrameCaptureData(); + const setSelectedInstruction = useDevtoolStore.use.setSelectedInstruction(); + const disableCaptureWithScreenshot = useDevtoolStore.use.disableCaptureWithScreenshot(); + const setDisableCaptureWithScreenshot = useDevtoolStore.use.setDisableCaptureWithScreenshot(); + + useInterval(() => { + const fetch = async () => { + const res = await bridge!(`window.__PIXI_DEVTOOLS_WRAPPER__.rendererType`); + if (res === null) return; + setDisableCaptureWithScreenshot(res === 'webgpu'); + if (res === 'webgpu') setCaptureWithScreenshot(false); + }; + + fetch(); + }, 1000); + + const onCapture = async () => { + setLoading(true); + const res = await bridge!(`window.__PIXI_DEVTOOLS_WRAPPER__.rendering.capture(${captureWithScreenshot})`); + setSelectedInstruction(null); + setFrameCaptureData(res as RenderingState['frameCaptureData']); + setLoading(false); + }; + + const onCaptureWithScreenshot = async (checked: boolean) => { + setCaptureWithScreenshot(checked); + }; + + return ( + +
+
+
+
+ +
+ +
+ + +
+
+
+ {loading ? ( +
Capturing...
+ ) : ( + + )} +
+
+ ); +}; diff --git a/packages/frontend/src/pages/rendering/RenderingPanel.tsx b/packages/frontend/src/pages/rendering/RenderingPanel.tsx index c6527a8..3586a3d 100644 --- a/packages/frontend/src/pages/rendering/RenderingPanel.tsx +++ b/packages/frontend/src/pages/rendering/RenderingPanel.tsx @@ -1,3 +1,77 @@ +import { useEffect, useState } from 'react'; +import { useDevtoolStore } from '../../App'; +import { InstructionsPanel } from './InstructionsPanel'; +import { RenderingStats } from './RenderingStats'; +import { CanvasPanel } from './CanvasPanel'; + export const RenderingPanel = () => { - return
Under Construction
; + const [version, setVersion] = useState(null); + const bridge = useDevtoolStore.use.bridge(); + + useEffect(() => { + async function fetchData() { + const res = await bridge!('window.__PIXI_DEVTOOLS_WRAPPER__.majorVersion'); + setVersion(res as string); + } + fetchData(); + }, [bridge]); + + if (!version) { + return null; + } + + if (Number(version) < 8) { + return ( +
+
+ This panel is only available for PixiJS 8 and above +
+
+ ); + } + + return ( +
+ + + + {/* +
+
+
+
+ +
+ +
+ + +
+
+
+ {loading ? ( +
Capturing...
+ ) : ( + + )} +
+
*/} +
+ ); }; diff --git a/packages/frontend/src/pages/rendering/RenderingStats.tsx b/packages/frontend/src/pages/rendering/RenderingStats.tsx new file mode 100644 index 0000000..fff79f7 --- /dev/null +++ b/packages/frontend/src/pages/rendering/RenderingStats.tsx @@ -0,0 +1,47 @@ +import { useDevtoolStore } from '../../App'; +import { SaveCollapsibleSection } from '../../components/collapsible/collapsible-section'; +import { CanvasStatComponent } from '../../components/smooth-charts/stat'; +import { useInterval } from '../../lib/interval'; +import { formatCamelCase, isDifferent } from '../../lib/utils'; +import type { RenderingState } from './rendering'; +import { useTheme } from '../../components/theme-provider'; + +export const RenderingStats: React.FC = () => { + const { theme } = useTheme(); + const bridge = useDevtoolStore.use.bridge(); + const renderingData = useDevtoolStore.use.renderingData(); + const setRenderingData = useDevtoolStore.use.setRenderingData(); + + useInterval(async () => { + const res = await bridge!('window.__PIXI_DEVTOOLS_WRAPPER__.rendering.captureRenderingData()'); + + if (isDifferent(res, renderingData)) { + setRenderingData(res as RenderingState['renderingData']); + } + }, 100); + + return ( + +
+
+ {renderingData && + Object.keys(renderingData!).map((key) => { + const item = renderingData![key as keyof typeof renderingData]; + return ( +
+ +
+ ); + })} +
+
+
+ ); +}; diff --git a/packages/frontend/src/pages/rendering/instructions/Batch.tsx b/packages/frontend/src/pages/rendering/instructions/Batch.tsx new file mode 100644 index 0000000..912518e --- /dev/null +++ b/packages/frontend/src/pages/rendering/instructions/Batch.tsx @@ -0,0 +1,41 @@ +import type React from 'react'; +import { memo } from 'react'; +import { propertyMap } from '../../../components/properties/propertyTypes'; +import { cn } from '../../../lib/utils'; +import { TextureViewer } from '../../assets/gpuTextures/TextureViewer'; +import type { BatchInstruction } from './Instructions'; +import { InstructionSection } from './shared/InstructionSection'; +import { PropertyEntries } from './shared/PropertyDisplay'; + +export const BatchView: React.FC = memo( + ({ textures, blendMode, start, size, drawTextures, action, type }) => { + return ( +
+
+ + + {textures.length > 0 && ( +
+
In Batch
+
+ {textures.map((t, i) => ( + + ))} +
+
+ )} +
+
+
+ ); + }, +); diff --git a/packages/frontend/src/pages/rendering/instructions/CustomRender.tsx b/packages/frontend/src/pages/rendering/instructions/CustomRender.tsx new file mode 100644 index 0000000..24e999a --- /dev/null +++ b/packages/frontend/src/pages/rendering/instructions/CustomRender.tsx @@ -0,0 +1,39 @@ +import type React from 'react'; +import { memo } from 'react'; +import { CollapsibleSection } from '../../../components/collapsible/collapsible-section'; +import { propertyMap } from '../../../components/properties/propertyTypes'; +import { TextureViewer } from '../../assets/gpuTextures/TextureViewer'; +import type { CustomRenderableInstruction } from './Instructions'; +import { InstructionSection } from './shared/InstructionSection'; +import { PropertyEntries } from './shared/PropertyDisplay'; + +export const CustomRenderView: React.FC = memo( + ({ renderable, drawTextures, type, action }) => { + return ( +
+
+ + +
+ {renderable.texture && ( + // center the texture viewer +
+ +
+ )} + +
+
+
+
+ ); + }, +); diff --git a/packages/frontend/src/pages/rendering/instructions/Filter.tsx b/packages/frontend/src/pages/rendering/instructions/Filter.tsx new file mode 100644 index 0000000..0fa16cc --- /dev/null +++ b/packages/frontend/src/pages/rendering/instructions/Filter.tsx @@ -0,0 +1,49 @@ +import type React from 'react'; +import { memo } from 'react'; +import { CollapsibleSection } from '../../../components/collapsible/collapsible-section'; +import { propertyMap } from '../../../components/properties/propertyTypes'; +import { TextureViewer } from '../../assets/gpuTextures/TextureViewer'; +import type { FilterInstruction } from './Instructions'; +import { InstructionSection } from './shared/InstructionSection'; +import { PropertyEntries } from './shared/PropertyDisplay'; +import { Shader } from './shared/Shader'; + +export const FilterView: React.FC = memo(({ renderables, filter, drawTextures, type, action }) => { + return ( +
+
+ + {filter?.map((filter, i) => ( + +
+ + + +
+
+ ))} + {renderables.map((renderable, i) => ( + +
+ {renderable.texture && ( + // center the texture viewer +
+ +
+ )} + +
+
+ ))} +
+
+ ); +}); diff --git a/packages/frontend/src/pages/rendering/instructions/Graphics.tsx b/packages/frontend/src/pages/rendering/instructions/Graphics.tsx new file mode 100644 index 0000000..cd97ee1 --- /dev/null +++ b/packages/frontend/src/pages/rendering/instructions/Graphics.tsx @@ -0,0 +1,36 @@ +import type React from 'react'; +import { memo } from 'react'; +import { CollapsibleSection } from '../../../components/collapsible/collapsible-section'; +import { propertyMap } from '../../../components/properties/propertyTypes'; +import type { GraphicsInstruction } from './Instructions'; +import { InstructionSection } from './shared/InstructionSection'; +import { PropertyEntries } from './shared/PropertyDisplay'; + +export const GraphicsView: React.FC = memo(({ renderable, drawTextures, action, type }) => { + return ( +
+
+ + +
+ {/* {renderable.texture && ( + // center the texture viewer +
+ +
+ )} */} + +
+
+
+
+ ); +}); diff --git a/packages/frontend/src/pages/rendering/instructions/Instructions.tsx b/packages/frontend/src/pages/rendering/instructions/Instructions.tsx new file mode 100644 index 0000000..3a02fbe --- /dev/null +++ b/packages/frontend/src/pages/rendering/instructions/Instructions.tsx @@ -0,0 +1,326 @@ +import type { FilterAntialias } from 'pixi.js'; +import React, { memo } from 'react'; +import { FaCircleDot as CaptureIcon } from 'react-icons/fa6'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { useDevtoolStore } from '../../../App'; +import { formatNumber } from '../../../lib/utils'; +import type { TextureDataState } from '../../assets/assets'; +import { BatchView } from './Batch'; +import { FilterView } from './Filter'; +import { GraphicsView } from './Graphics'; +import { MaskView } from './Mask'; +import { MeshView } from './Mesh'; +import { NineSliceSpriteView } from './NineSliceSprite'; +import { InstructionPill } from './shared/InstructionBubble'; +import type { InstructionPillProps } from './shared/InstructionBubble'; +import { TilingSpriteView } from './TilingSprite'; +import { CustomRenderView } from './CustomRender'; +import { RenderGroupView } from './RenderGroup'; + +export type RenderingTextureDataState = Pick< + TextureDataState, + 'blob' | 'pixelWidth' | 'pixelHeight' | 'name' | 'gpuSize' +>; + +type XY = { + x: number; + y: number; +}; +export type RenderableData = { + class: string; + type: string; + label: string; + anchor: XY | null; + roundPixels: boolean | null; + position: XY; + width: number; + height: number; + scale: XY; + rotation: number; + angle: number; + pivot: XY; + skew: XY; + visible: boolean; + renderable: boolean; + alpha: number; + tint: number; + filterArea: (XY & { width: number; height: number }) | null; + sortableChildren: boolean; + zIndex: number; + blendMode: string; + boundsArea: (XY & { width: number; height: number }) | null; + isRenderGroup: boolean; + cullable: boolean; + cullArea: (XY & { width: number; height: number }) | null; + cullableChildren: boolean; +}; + +export interface StateData { + blend: boolean; + blendMode: string; + clockwiseFrontFace: boolean; + cullMode: string; + culling: boolean; + depthMask: boolean; + depthTest: boolean; + offsets: boolean; + polygonOffset: number; +} + +export interface BaseInstruction { + type: string; + action: string; + drawCalls: number; + drawTextures: string[]; +} + +export interface BatchInstruction extends BaseInstruction { + type: 'batch'; + action: 'startBatch' | 'renderBatch'; + blendMode: string; + size: number; + start: number; + textures: RenderingTextureDataState[]; // base64 encoded + // TODO: need to find how to get elements of a batch +} + +export interface MaskInstruction extends BaseInstruction { + type: 'stencilMask' | 'colorMask' | 'alphaMask'; + action: string; + mask: + | ({ + width: number; + height: number; + } & RenderableData) + | null; +} + +export interface FilterInstruction extends BaseInstruction { + type: 'filter'; + action: 'pushFilter' | 'popFilter'; + filter: + | { + type: string; + padding: number; + resolution: number; + antialias: FilterAntialias; + blendMode: string; + program: { + fragment: string; + vertex: string; + }; + state: StateData; + }[] + | null; + renderables: ({ + texture: RenderingTextureDataState | null; + } & RenderableData)[]; +} + +export interface TilingSpriteInstruction extends BaseInstruction { + type: 'tilingSprite'; + action: 'draw'; + renderable: { + texture: RenderingTextureDataState | null; + tilePosition: { + x: number; + y: number; + }; + tileScale: { + x: number; + y: number; + }; + tileRotation: number; + clampMargin: number; + } & RenderableData; +} + +export interface NineSliceInstruction extends BaseInstruction { + type: 'nineSlice'; + action: 'draw'; + renderable: { + texture: RenderingTextureDataState | null; + leftWidth: number; + rightWidth: number; + topHeight: number; + bottomHeight: number; + originalWidth: number; + originalHeight: number; + } & RenderableData; +} + +export interface CustomRenderableInstruction extends BaseInstruction { + type: 'customRenderable'; + action: 'draw'; + renderable: { + texture: RenderingTextureDataState | null; + } & RenderableData; +} + +export interface MeshInstruction extends BaseInstruction { + type: 'mesh'; + action: string; + renderable: { + state: StateData; + program: { + fragment: string | null; + vertex: string | null; + }; + texture: RenderingTextureDataState | null; + geometry: { + positions: number[]; + uvs: number[]; + indices: number[]; + }; + } & RenderableData; +} + +export interface GraphicsInstruction extends BaseInstruction { + type: 'graphics'; + action: string; + renderable: RenderableData; +} + +const _colors = [ + 'fill-insRed', + 'fill-insOrange', + 'fill-insYellow', + 'fill-insDarkBlue', + 'fill-insCyan', + 'fill-insPink', + 'fill-insPurple', + 'fill-insBlue', + 'fill-insLime', + 'fill-insTan', +]; + +const instructionViews = { + batch: BatchView, + stencilMask: MaskView, + colorMask: MaskView, + alphaMask: MaskView, + filter: FilterView, + tilingSprite: TilingSpriteView, + mesh: MeshView, + graphics: GraphicsView, + nineSliceSprite: NineSliceSpriteView, + customRender: CustomRenderView, + 'Render Group': RenderGroupView, +}; + +export const Instructions: React.FC = memo(() => { + const selectedInstruction = useDevtoolStore.use.selectedInstruction(); + const setSelectedInstruction = useDevtoolStore.use.setSelectedInstruction(); + const frameCaptureData = useDevtoolStore.use.frameCaptureData(); + + if (!frameCaptureData) { + return ( +
+
+ Click the button to capture the scene +
+
+ ); + } + + const { instructions, drawCalls, renderTime, totals } = frameCaptureData; + + const metrics = [ + { label: 'Draw Calls', value: drawCalls }, + { label: 'Render Time', value: `${formatNumber(renderTime, 3)}ms` }, + { label: 'Filters', value: totals.filters }, + { label: 'Masks', value: totals.masks }, + { label: 'Containers', value: totals.containers }, + { label: 'Sprites', value: totals.sprites }, + { label: 'Tiling Sprites', value: totals.tilingSprites }, + { label: 'NineSlice Sprite', value: totals.nineSliceSprites }, + { label: 'Graphics', value: totals.graphics }, + { label: 'Meshes', value: totals.meshes }, + { label: 'Texts', value: totals.texts }, + ]; + + let renderGroupDepth = 0; + const renderGroupColorMap: Map = new Map(); + + return ( +
+
+ + +
+
+ {metrics.map((metric, index) => ( +
+ {metric.label}: + {metric.value} +
+ ))} +
+
+
+ + +
+
+ {instructions!.map((instruction, i) => { + const paddingAmount = 10; + let paddingLeft = renderGroupDepth * paddingAmount; + + if (instruction.type === 'Render Group' && instruction.action === 'start') { + renderGroupDepth++; + renderGroupColorMap.set( + 'RenderGroup: ' + renderGroupDepth, + _colors[renderGroupDepth % _colors.length], + ); + } + + const commonProps: InstructionPillProps = { + onClick: () => setSelectedInstruction(i), + selected: selectedInstruction === i, + drawTextures: instruction.drawTextures.length > 0 ? instruction.drawTextures : undefined, + type: instruction.type, + action: instruction.action, + renderGroupColor: renderGroupColorMap.get('RenderGroup: ' + renderGroupDepth)!, + isDrawCall: instruction.drawCalls > 0, + }; + + if (instruction.type === 'Render Group' && instruction.action === 'end') { + renderGroupDepth = Math.max(0, renderGroupDepth - 1); + paddingLeft = renderGroupDepth * paddingAmount; + } + + // add padding to the left based on the depth of the render group + return ( +
+ +
+ ); + })} +
+
+
+ + +
+
+ {selectedInstruction !== null && + (() => { + const InstructionView: React.FC = + instructionViews[instructions![selectedInstruction].type as keyof typeof instructionViews] || + (() =>
Unknown instruction type
); + + return ( + + ); + })()} +
+
+
+
+
+
+ ); +}); diff --git a/packages/frontend/src/pages/rendering/instructions/Mask.tsx b/packages/frontend/src/pages/rendering/instructions/Mask.tsx new file mode 100644 index 0000000..69f8961 --- /dev/null +++ b/packages/frontend/src/pages/rendering/instructions/Mask.tsx @@ -0,0 +1,61 @@ +import type React from 'react'; +import { memo } from 'react'; +import { CollapsibleSection } from '../../../components/collapsible/collapsible-section'; +import { propertyMap } from '../../../components/properties/propertyTypes'; +import type { MaskInstruction } from './Instructions'; +import { PropertyEntries } from './shared/PropertyDisplay'; + +export const MaskView: React.FC = memo(({ mask, type, action }) => { + if (mask) { + return ( +
+
+ +
+ +
+
+ +
+ {/* {mask.texture && ( + // center the texture viewer +
+ +
+ )} */} + +
+
+
+
+ //
+ //
+ //
Class: {mask.class}
+ //
Type: {mask.type}
+ //
Label: {mask.label}
+ //
Width: {formatNumber(mask.width, 3)}
+ //
Height: {formatNumber(mask.height, 3)}
+ //
+ //
+ ); + } + return <>; +}); + +{ + /* } + isLast={i === Object.keys(selectedTexture).length - 1} +/>; */ +} diff --git a/packages/frontend/src/pages/rendering/instructions/Mesh.tsx b/packages/frontend/src/pages/rendering/instructions/Mesh.tsx new file mode 100644 index 0000000..fb30c23 --- /dev/null +++ b/packages/frontend/src/pages/rendering/instructions/Mesh.tsx @@ -0,0 +1,66 @@ +import type React from 'react'; +import { memo } from 'react'; +import { CollapsibleSection } from '../../../components/collapsible/collapsible-section'; +import { propertyMap } from '../../../components/properties/propertyTypes'; +import { TextureViewer } from '../../assets/gpuTextures/TextureViewer'; +import type { MeshInstruction } from './Instructions'; +import { InstructionSection } from './shared/InstructionSection'; +import { PropertyEntries } from './shared/PropertyDisplay'; +import { Shader } from './shared/Shader'; + +export const MeshView: React.FC = memo(({ renderable, drawTextures, type, action }) => { + const formatFloat32Array = (array: number[]) => { + return `[${array.join(', ')}]`; + }; + + return ( +
+
+ + +
+ {renderable.texture && ( + // center the texture viewer +
+ +
+ )} + + +
+
Indices
+
+ {formatFloat32Array(renderable.geometry.indices)} +
+
+
+
UVs
+
+ {formatFloat32Array(renderable.geometry.uvs)} +
+
+
+
Positions
+
+ {formatFloat32Array(renderable.geometry.positions)} +
+
+ +
+
+
+
+ ); +}); diff --git a/packages/frontend/src/pages/rendering/instructions/NineSliceSprite.tsx b/packages/frontend/src/pages/rendering/instructions/NineSliceSprite.tsx new file mode 100644 index 0000000..4b4940c --- /dev/null +++ b/packages/frontend/src/pages/rendering/instructions/NineSliceSprite.tsx @@ -0,0 +1,39 @@ +import type React from 'react'; +import { memo } from 'react'; +import { CollapsibleSection } from '../../../components/collapsible/collapsible-section'; +import { propertyMap } from '../../../components/properties/propertyTypes'; +import { TextureViewer } from '../../assets/gpuTextures/TextureViewer'; +import type { NineSliceInstruction } from './Instructions'; +import { InstructionSection } from './shared/InstructionSection'; +import { PropertyEntries } from './shared/PropertyDisplay'; + +export const NineSliceSpriteView: React.FC = memo( + ({ renderable, drawTextures, type, action }) => { + return ( +
+
+ + +
+ {renderable.texture && ( + // center the texture viewer +
+ +
+ )} + +
+
+
+
+ ); + }, +); diff --git a/packages/frontend/src/pages/rendering/instructions/RenderGroup.tsx b/packages/frontend/src/pages/rendering/instructions/RenderGroup.tsx new file mode 100644 index 0000000..fc3929e --- /dev/null +++ b/packages/frontend/src/pages/rendering/instructions/RenderGroup.tsx @@ -0,0 +1,28 @@ +import type React from 'react'; +import { memo } from 'react'; +import type { BaseInstruction } from './Instructions'; +import { InstructionSection } from './shared/InstructionSection'; + +export const RenderGroupView: React.FC = memo(({ drawTextures, type, action }) => { + return ( +
+
+
+ Render groups are used to group instructions together. They can be used to optimise your scene by reducing the + amount of instruction rebuilds that need to be done. +
+
+ You can learn more about render groups{' '} + + here + +
+ +
+
+ ); +}); diff --git a/packages/frontend/src/pages/rendering/instructions/TilingSprite.tsx b/packages/frontend/src/pages/rendering/instructions/TilingSprite.tsx new file mode 100644 index 0000000..89bd59c --- /dev/null +++ b/packages/frontend/src/pages/rendering/instructions/TilingSprite.tsx @@ -0,0 +1,49 @@ +import type React from 'react'; +import { memo } from 'react'; +import { CollapsibleSection } from '../../../components/collapsible/collapsible-section'; +import { propertyMap } from '../../../components/properties/propertyTypes'; +import { TextureViewer } from '../../assets/gpuTextures/TextureViewer'; +import type { TilingSpriteInstruction } from './Instructions'; +import { InstructionSection } from './shared/InstructionSection'; +import { PropertyEntries } from './shared/PropertyDisplay'; + +export const TilingSpriteView: React.FC = memo( + ({ renderable, drawTextures, type, action }) => { + return ( +
+
+ + +
+ {renderable.texture && ( + // center the texture viewer +
+ +
+ )} + +
+
+
+
+ ); + }, +); + +{ + /* } + isLast={i === Object.keys(selectedTexture).length - 1} +/>; */ +} diff --git a/packages/frontend/src/pages/rendering/instructions/shared/InstructionBubble.tsx b/packages/frontend/src/pages/rendering/instructions/shared/InstructionBubble.tsx new file mode 100644 index 0000000..f1312cc --- /dev/null +++ b/packages/frontend/src/pages/rendering/instructions/shared/InstructionBubble.tsx @@ -0,0 +1,46 @@ +import type React from 'react'; +import { memo } from 'react'; +import { FaCircle as CircleIcon } from 'react-icons/fa'; +import { cn } from '../../../../lib/utils'; +import { Texture } from './Texture'; + +// - instruction pill rules +// - circle dot color that matches the render group its a part of +// - red outline if its a draw call - show blue if renderBatch +// - secondary outline if hovered +// - primary outline if selected +// - show type, action, draw texture (if exists) +export interface InstructionPillProps { + onClick: () => void; + selected: boolean; + renderGroupColor: string; + drawTextures?: string[]; + type: string; + action: string; + isDrawCall?: boolean; +} +export const InstructionPill: React.FC = memo( + ({ onClick, selected, renderGroupColor, drawTextures, type, action, isDrawCall }) => { + const border = selected ? 'bg-primary' : isDrawCall ? 'border-primary' : ''; + const text = selected ? 'text-white' : ''; + return ( +
+
+ +
+
+
Type: {type}
+
Action: {action}
+ {drawTextures && drawTextures.map((texture, i) => )} +
+
+ ); + }, +); diff --git a/packages/frontend/src/pages/rendering/instructions/shared/InstructionSection.tsx b/packages/frontend/src/pages/rendering/instructions/shared/InstructionSection.tsx new file mode 100644 index 0000000..d200a46 --- /dev/null +++ b/packages/frontend/src/pages/rendering/instructions/shared/InstructionSection.tsx @@ -0,0 +1,35 @@ +import { CollapsibleSection } from '../../../../components/collapsible/collapsible-section'; +import { propertyMap } from '../../../../components/properties/propertyTypes'; +import { memo } from 'react'; +import { PropertyEntries } from './PropertyDisplay'; +import { Texture } from './Texture'; +import { cn } from '../../../../lib/utils'; + +interface InstructionSectionProps { + drawTextures: string[]; + type: string; + action: string; + children?: React.ReactNode; +} +export const InstructionSection: React.FC = memo( + ({ drawTextures, type, action, children }) => { + return ( + +
+ + {children} + {drawTextures.length > 0 && ( +
+
Draw Call Textures
+
+ {drawTextures.map((texture, i) => ( + + ))} +
+
+ )} +
+
+ ); + }, +); diff --git a/packages/frontend/src/pages/rendering/instructions/shared/PropertyDisplay.tsx b/packages/frontend/src/pages/rendering/instructions/shared/PropertyDisplay.tsx new file mode 100644 index 0000000..91597a7 --- /dev/null +++ b/packages/frontend/src/pages/rendering/instructions/shared/PropertyDisplay.tsx @@ -0,0 +1,75 @@ +import type React from 'react'; +import { PropertyEntry } from '../../../../components/properties/propertyEntry'; + +interface PropertyEntriesProps { + renderable: Record; + propertyMap: { + vector2: React.ComponentType; + vectorX: React.ComponentType; + text: React.ComponentType; + }; + ignore?: string[]; +} + +export const PropertyEntries: React.FC = ({ renderable, propertyMap, ignore }) => { + const formatCamelCase = (str: string) => { + // Assuming formatCamelCase is defined elsewhere + return str.replace(/([A-Z])/g, ' $1').replace(/^./, (str) => str.toUpperCase()); + }; + + const comps = Object.entries(renderable).map(([key, value]) => { + const val = value; + const formattedKey = formatCamelCase(key); + if (key === 'texture' || value === null || (ignore && ignore.includes(key))) { + return null; + } + + if (value instanceof Object && 'x' in value && 'y' in value && !('width' in value)) { + const Vector2 = propertyMap.vector2; + + const props = { + value: [value.x, value.y], + prop: key, + entry: { + options: { x: { label: 'x', disabled: true }, y: { label: 'y', disabled: true } }, + onChange: () => {}, + }, + }; + + return } isLast={true} />; + } else if (value instanceof Object && 'x' in value && 'y' in value && 'width' in value) { + const VectorX = propertyMap.vectorX; + + const props = { + value: [value.x, value.y, value.width, value.height], + prop: key, + entry: { + options: { + inputs: [ + { label: 'x', disabled: true }, + { label: 'y', disabled: true }, + { label: 'width', disabled: true }, + { label: 'height', disabled: true }, + ], + }, + onChange: () => {}, + }, + }; + + return } isLast={true} />; + } + + const TextComp = propertyMap.text; + const props = { + value: val, + prop: key, + entry: { + options: { disabled: true }, + onChange: () => {}, + }, + }; + return } isLast={true} />; + }); + + return <>{comps}; +}; diff --git a/packages/frontend/src/pages/rendering/instructions/shared/Shader.tsx b/packages/frontend/src/pages/rendering/instructions/shared/Shader.tsx new file mode 100644 index 0000000..961468e --- /dev/null +++ b/packages/frontend/src/pages/rendering/instructions/shared/Shader.tsx @@ -0,0 +1,64 @@ +import { useTheme } from '../../../../components/theme-provider'; +import React, { memo } from 'react'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { dracula, prism } from 'react-syntax-highlighter/dist/esm/styles/prism'; + +interface ShaderProps { + vertex: string; + fragment: string; +} +export const Shader: React.FC = memo(({ vertex, fragment }) => { + return ( + <> + + + + ); +}); + +export const ShaderView: React.FC<{ value: string; title: string }> = memo(({ value, title }) => { + const valueFix = formatShader(value); + const { theme } = useTheme(); + const style = theme === 'dark' ? dracula : prism; + return ( +
+
{title}
+
+ + {valueFix} + +
+
+ ); +}); + +/** + * formats a shader so its more pleasant to read! + * @param shader - a glsl shader program source + */ +function formatShader(shader: string): string { + const spl = shader + .split(/([\n{}])/g) + .map((a) => a.trim()) + .filter((a) => a.length); + + let indent = ''; + + const formatted = spl + .map((a) => { + let indentedLine = indent + a; + + if (a === '{') { + indent += ' '; + } else if (a === '}') { + indent = indent.substr(0, indent.length - 4); + + indentedLine = indent + a; + } + + return indentedLine; + }) + .join('\n'); + + return formatted; +} diff --git a/packages/frontend/src/pages/rendering/instructions/shared/State.tsx b/packages/frontend/src/pages/rendering/instructions/shared/State.tsx new file mode 100644 index 0000000..06c735b --- /dev/null +++ b/packages/frontend/src/pages/rendering/instructions/shared/State.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import type { StateData } from '../Instructions'; + +export const State: React.FC = ({ + blend, + blendMode, + clockwiseFrontFace, + cullMode, + culling, + depthMask, + depthTest, + offsets, + polygonOffset, +}) => { + return ( +
+
State
+
State Blend: {blend ? 'true' : 'false'}
+
State BlendMode: {blendMode}
+
State Clockwise Front Face: {clockwiseFrontFace ? 'true' : 'false'}
+
State CullMode: {cullMode}
+
State Culling: {culling ? 'true' : 'false'}
+
State Depth Mask: {depthMask ? 'true' : 'false'}
+
State Depth Test: {depthTest ? 'true' : 'false'}
+
State Offsets: {offsets ? 'true' : 'false'}
+
State Polygon Offset: {polygonOffset}
+
+ ); +}; diff --git a/packages/frontend/src/pages/rendering/instructions/shared/Texture.tsx b/packages/frontend/src/pages/rendering/instructions/shared/Texture.tsx new file mode 100644 index 0000000..4ace723 --- /dev/null +++ b/packages/frontend/src/pages/rendering/instructions/shared/Texture.tsx @@ -0,0 +1,51 @@ +import React, { memo } from 'react'; +import { useTheme } from '../../../../components/theme-provider'; +import { cn } from '../../../../lib/utils'; + +interface TextureProps { + texture: string; + size?: number; + border?: string; +} +export const Texture: React.FC = memo(({ texture, size, border }) => { + const { theme } = useTheme(); + const w = `w-${size ?? 40}`; + const h = `h-${size ?? 40}`; + const b = border ?? 'border-background'; + const darkColor1 = 'hsla(0, 0%, 15%, 1)'; + const darkColor2 = 'hsla(0, 0%, 24%, 1)'; + const lightColor1 = 'hsla(0, 0%, 100%, 1)'; + const lightColor2 = 'hsla(0, 0%, 90%, 1)'; + const gridColor1 = theme === 'dark' ? darkColor1 : lightColor1; + const gridColor2 = theme === 'dark' ? darkColor2 : lightColor2; + const svgString = encodeURIComponent( + ` + + + + + + + + + + `, + ); + return ( +
+
+
+ content +
+
+
+ ); +}); diff --git a/packages/frontend/src/pages/rendering/instructions/test.tsx b/packages/frontend/src/pages/rendering/instructions/test.tsx new file mode 100644 index 0000000..e69de29 diff --git a/packages/frontend/src/pages/rendering/rendering.ts b/packages/frontend/src/pages/rendering/rendering.ts new file mode 100644 index 0000000..058bf89 --- /dev/null +++ b/packages/frontend/src/pages/rendering/rendering.ts @@ -0,0 +1,110 @@ +import type { ZustSet } from '../../lib/utils'; +import type { + BaseInstruction, + BatchInstruction, + CustomRenderableInstruction, + FilterInstruction, + GraphicsInstruction, + MaskInstruction, + MeshInstruction, + NineSliceInstruction, + TilingSpriteInstruction, +} from './instructions/Instructions'; + +export interface FrameCaptureData { + instructions: + | ( + | BaseInstruction + | BatchInstruction + | MaskInstruction + | FilterInstruction + | TilingSpriteInstruction + | MeshInstruction + | GraphicsInstruction + | NineSliceInstruction + | CustomRenderableInstruction + )[] + | null; + drawCalls: number; + renderTime: number; + totals: { + containers: number; + graphics: number; + meshes: number; + sprites: number; + texts: number; + tilingSprites: number; + nineSliceSprites: number; + filters: number; + masks: number; + }; +} + +export interface CanvasData { + width: number; + height: number; + clientWidth: number; + clientHeight: number; + browserAgent: string; + backgroundAlpha: string; + antialias: string; + depth: string; + premultipliedAlpha: string; + preserveDrawingBuffer: string; + powerPreference: 'default' | 'high-performance' | 'low-power'; + type: 'webgl' | 'webgl2' | 'webgpu'; + resolution: string; + roundPixels: string; + autoDensity: string; + background: string; + clearBeforeRender: string; + failIfMajorPerformanceCaveat: string | undefined; + renderableGCFrequency: string; + renderableGCActive: string; + renderableGCMaxUnusedTime: string; + textureGCAMaxIdle: string; + textureGCActive: string; + textureGCCheckCountMax: string; +} +export interface RenderingState { + selectedInstruction: number | null; + setSelectedInstruction: (instruction: RenderingState['selectedInstruction']) => void; + + renderingData: { drawCalls: number; fps: number; rebuildFrequency: number } | null; + setRenderingData: (data: RenderingState['renderingData']) => void; + + frameCaptureData: FrameCaptureData | null; + setFrameCaptureData: (data: RenderingState['frameCaptureData']) => void; + + captureWithScreenshot: boolean; + setCaptureWithScreenshot: (value: boolean) => void; + + disableCaptureWithScreenshot: boolean; + setDisableCaptureWithScreenshot: (value: boolean) => void; + + canvasData: CanvasData | null; + setCanvasData: (data: RenderingState['canvasData']) => void; +} + +export const renderingStateSlice = (set: ZustSet) => ({ + selectedInstruction: null, + setSelectedInstruction: (instruction: RenderingState['selectedInstruction']) => + set((state) => ({ ...state, selectedInstruction: instruction })), + + renderingData: null, + setRenderingData: (data: RenderingState['renderingData']) => set((state) => ({ ...state, renderingData: data })), + + frameCaptureData: null, + setFrameCaptureData: (data: RenderingState['frameCaptureData']) => + set((state) => ({ ...state, frameCaptureData: data })), + + captureWithScreenshot: true, + setCaptureWithScreenshot: (value: boolean) => set((state) => ({ ...state, captureWithScreenshot: value })), + + disableCaptureWithScreenshot: false, + setDisableCaptureWithScreenshot: (value: boolean) => + set((state) => ({ ...state, disableCaptureWithScreenshot: value })), + + canvasData: null, + setCanvasData: (data: RenderingState['canvasData']) => set((state) => ({ ...state, canvasData: data })), +}); diff --git a/packages/frontend/src/pages/scene/stats-section/Stats.tsx b/packages/frontend/src/pages/scene/stats-section/Stats.tsx index c3e2f95..095a9f3 100644 --- a/packages/frontend/src/pages/scene/stats-section/Stats.tsx +++ b/packages/frontend/src/pages/scene/stats-section/Stats.tsx @@ -1,8 +1,8 @@ +import { memo, useEffect, useMemo, useRef } from 'react'; import { useDevtoolStore } from '../../../App'; -import { CollapsibleSection } from '../../../components/collapsible/collapsible-section'; import SmoothieComponent, { TimeSeries } from '../../../components/smooth-charts/Smoothie'; import { useTheme } from '../../../components/theme-provider'; -import { memo, useEffect, useMemo, useRef } from 'react'; +import { SaveCollapsibleSection } from '../../../components/collapsible/collapsible-section'; const SmoothieStat: React.FC<{ item: StatData; @@ -106,7 +106,7 @@ export const Stats: React.FC = () => { }, [stats]); return ( - +
{currentStats.map((item) => { @@ -122,6 +122,6 @@ export const Stats: React.FC = () => { })}
-
+ ); }; diff --git a/packages/frontend/src/types.ts b/packages/frontend/src/types.ts index b255783..09fb491 100644 --- a/packages/frontend/src/types.ts +++ b/packages/frontend/src/types.ts @@ -1,5 +1,6 @@ import type { BridgeFn } from './lib/utils'; import type { TextureState } from './pages/assets/assets'; +import type { RenderingState } from './pages/rendering/rendering'; import type { SceneState } from './pages/scene/state'; import type { ButtonMetadata, PixiMetadata } from '@pixi/devtools'; @@ -17,7 +18,7 @@ export type SceneGraphEntry = { children: SceneGraphEntry[]; }; -export interface DevtoolState extends SceneState, TextureState { +export interface DevtoolState extends SceneState, TextureState, RenderingState { active: boolean; setActive: (active: DevtoolState['active']) => void; diff --git a/packages/frontend/tailwind.config.js b/packages/frontend/tailwind.config.js index 030d5e7..101b906 100644 --- a/packages/frontend/tailwind.config.js +++ b/packages/frontend/tailwind.config.js @@ -19,6 +19,18 @@ export default { ring: 'hsl(var(--ring))', background: 'hsl(var(--background))', foreground: 'hsl(var(--foreground))', + insRed: 'hsl(var(--instruction-red))', + insOrange: 'hsl(var(--instruction-orange))', + insYellow: 'hsl(var(--instruction-yellow))', + insDarkBlue: 'hsl(var(--instruction-dark-blue))', + insCyan: 'hsl(var(--instruction-cyan))', + + insPink: 'hsl(var(--instruction-pink))', + insPurple: 'hsl(var(--instruction-purple))', + insBlue: 'hsl(var(--instruction-blue))', + insLime: 'hsl(var(--instruction-lime))', + insTan: 'hsl(var(--instruction-tan))', + primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))',