From 8a620985f7927c4c7d1232b28f44a2b579cfb591 Mon Sep 17 00:00:00 2001 From: Emily-TTG Date: Thu, 17 Sep 2026 13:37:44 +0100 Subject: [PATCH 1/4] feat: introduce WebGL1 buffer data capture --- src/backend/recorders/baseRecorder.ts | 2 +- src/backend/recorders/bufferRecorder.ts | 146 +++++++++++++++++++++++- src/backend/utils/base64.ts | 11 ++ src/backend/utils/rawTextureData.ts | 14 +-- src/shared/capture/bufferDataCapture.ts | 8 ++ src/shared/capture/capture.ts | 2 + 6 files changed, 170 insertions(+), 13 deletions(-) create mode 100644 src/backend/utils/base64.ts create mode 100644 src/shared/capture/bufferDataCapture.ts diff --git a/src/backend/recorders/baseRecorder.ts b/src/backend/recorders/baseRecorder.ts index b13e8229..39afbb7a 100644 --- a/src/backend/recorders/baseRecorder.ts +++ b/src/backend/recorders/baseRecorder.ts @@ -88,7 +88,7 @@ export abstract class BaseRecorder implements IRecorder { private totalMemory: number; private frameMemory: number; - private capturing: boolean; + protected capturing: boolean; constructor(protected readonly options: IContextInformation) { this.createCommandNames = this.getCreateCommandNames(); diff --git a/src/backend/recorders/bufferRecorder.ts b/src/backend/recorders/bufferRecorder.ts index a1ac7a50..75f58a27 100644 --- a/src/backend/recorders/bufferRecorder.ts +++ b/src/backend/recorders/bufferRecorder.ts @@ -1,6 +1,9 @@ import { BaseRecorder } from "./baseRecorder"; import { WebGlConstants } from "../types/webglConstants"; import { IFunctionInformation } from "../types/functionInformation"; +import { WebGlObjects } from "../webGlObjects/baseWebGlObject"; +import { ICapture } from "../../shared/capture/capture"; +import { Base64 } from "../utils/base64"; export interface IBufferRecorderData { target: string; @@ -10,7 +13,19 @@ export interface IBufferRecorderData { sourceLength?: number; } +interface ICapturedBuffer { + /** Truncated to {@link BufferRecorder.cap} when the buffer is larger. */ + bytes: Uint8Array; + /** Total length even when truncated. */ + byteLength: number; + usage?: number; +} + export class BufferRecorder extends BaseRecorder { + public static cap: number = 4 * 1024 * 1024; + + private capturedBuffers: { [id: number]: ICapturedBuffer } = {}; + protected get objectName(): string { return "Buffer"; } @@ -20,13 +35,39 @@ export class BufferRecorder extends BaseRecorder { } protected getUpdateCommandNames(): string[] { - return ["bufferData"]; + return ["bufferData", "bufferSubData"]; } protected getDeleteCommandNames(): string[] { return ["deleteBuffer"]; } + public startCapture(): void { + super.startCapture(); + this.capturedBuffers = {}; + } + + public appendRecordedInformation(capture: ICapture): void { + super.appendRecordedInformation(capture); + + const ids = Object.keys(this.capturedBuffers); + if (ids.length === 0) { + return; + } + + capture.buffers = capture.buffers || {}; + for (const key of ids) { + const id = Number(key); + const captured = this.capturedBuffers[id]; + capture.buffers[id] = { + data: Base64.encode(captured.bytes), + byteLength: captured.byteLength, + capped: captured.byteLength > captured.bytes.length, + usage: captured.usage === undefined ? undefined : this.getWebGlConstant(captured.usage), + }; + } + } + protected getBoundInstance(target: number): WebGLTexture { const gl = this.options.context; if (target === WebGlConstants.ARRAY_BUFFER.value) { @@ -57,6 +98,11 @@ export class BufferRecorder extends BaseRecorder { } protected delete(instance: WebGLBuffer): number { + const id = this.getInstanceId(instance); + if (id !== undefined) { + delete this.capturedBuffers[id]; + } + const customData = (instance as any).__SPECTOR_Object_CustomData; if (!customData) { return 0; @@ -66,6 +112,15 @@ export class BufferRecorder extends BaseRecorder { } protected update(functionInformation: IFunctionInformation, target: string, instance: WebGLBuffer): number { + const id = this.getInstanceId(instance); + + if (functionInformation.name === "bufferSubData") { + if (this.capturing && id !== undefined) { + this.captureBufferSubData(id, functionInformation); + } + return 0; + } + const customData = this.getCustomData(target, functionInformation); if (!customData) { return 0; @@ -73,6 +128,11 @@ export class BufferRecorder extends BaseRecorder { const previousLength = (instance as any).__SPECTOR_Object_CustomData ? (instance as any).__SPECTOR_Object_CustomData.length : 0; (instance as any).__SPECTOR_Object_CustomData = customData; + + if (this.capturing && id !== undefined) { + this.captureBufferData(id, functionInformation, customData.usage); + } + return customData.length - previousLength; } @@ -126,4 +186,88 @@ export class BufferRecorder extends BaseRecorder { return dataLength; } } + + /** Snapshot the bytes uploaded by a bufferData call. */ + private captureBufferData(id: number, functionInformation: IFunctionInformation, usage: number): void { + const sizeOrData = functionInformation.arguments[1]; + + // bufferData(target, size, usage): storage is allocated zero-filled and only filled + // later by bufferSubData, so seed a zero buffer that those writes can splice into. + if (typeof sizeOrData === "number") { + const byteLength = sizeOrData; + this.capturedBuffers[id] = { + bytes: new Uint8Array(Math.min(byteLength, BufferRecorder.cap)), + byteLength, + usage, + }; + return; + } + + const source = this.extractSourceBytes(sizeOrData, functionInformation.arguments[3], functionInformation.arguments[4]); + this.capturedBuffers[id] = { + bytes: source.slice(0, Math.min(source.length, BufferRecorder.cap)), + byteLength: source.length, + usage, + }; + } + + /** Merge the bytes written by a bufferSubData call into the stored copy. */ + private captureBufferSubData(id: number, functionInformation: IFunctionInformation): void { + const dstByteOffset = functionInformation.arguments[1] as number; + const source = functionInformation.arguments[2]; + if (!source || typeof source === "number") { + return; + } + + const bytes = this.extractSourceBytes(source, functionInformation.arguments[3], functionInformation.arguments[4]); + const writeEnd = dstByteOffset + bytes.length; + + let captured = this.capturedBuffers[id]; + if (!captured) { + captured = { + bytes: new Uint8Array(Math.min(writeEnd, BufferRecorder.cap)), + byteLength: writeEnd, + }; + this.capturedBuffers[id] = captured; + } + else if (writeEnd > captured.byteLength) { + captured.byteLength = writeEnd; + } + + // Splice into the stored window. + const capLimit = captured.bytes.length; + for (let i = 0; i < bytes.length; i++) { + const destination = dstByteOffset + i; + if (destination >= capLimit) { + break; + } + captured.bytes[destination] = bytes[i]; + } + } + + private extractSourceBytes(source: ArrayBuffer | ArrayBufferView, srcOffset: number, length: number): Uint8Array { + let bytes: Uint8Array; + if (source instanceof ArrayBuffer) { + bytes = new Uint8Array(source); + } + else { + const view = source as ArrayBufferView; + bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + } + + // srcOffset/length are counted in the source view's elements, not bytes. + const bytesPerElement = (source as any).BYTES_PER_ELEMENT || 1; + if (typeof srcOffset === "number" && srcOffset > 0) { + bytes = bytes.subarray(srcOffset * bytesPerElement); + } + if (typeof length === "number" && length > 0) { + bytes = bytes.subarray(0, length * bytesPerElement); + } + return bytes; + } + + private getInstanceId(instance: WebGLBuffer): number { + const tag = WebGlObjects.getWebGlObjectTag(instance); + return tag ? tag.id : undefined; + } } diff --git a/src/backend/utils/base64.ts b/src/backend/utils/base64.ts new file mode 100644 index 00000000..2f4e8a5f --- /dev/null +++ b/src/backend/utils/base64.ts @@ -0,0 +1,11 @@ +export class Base64 { + /** Base64-encode a byte buffer in stack-safe chunks (works in Workers via `btoa`). */ + public static encode(bytes: Uint8Array): string { + let binary = ""; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize) as any); + } + return btoa(binary); + } +} diff --git a/src/backend/utils/rawTextureData.ts b/src/backend/utils/rawTextureData.ts index 8f60cb1e..f91acb07 100644 --- a/src/backend/utils/rawTextureData.ts +++ b/src/backend/utils/rawTextureData.ts @@ -9,6 +9,8 @@ * transparent texels. Encoding the raw `readPixels` bytes here — before any * canvas round-trip — keeps that colour available to the viewer. */ +import { Base64 } from "./base64"; + export interface IRawTextureData { /** Base64 of the RGBA bytes, top-down, row-major (`width * height * 4` long). */ data: string; @@ -82,16 +84,6 @@ export class RawTextureData { } } - return { data: RawTextureData.toBase64(out), width: targetWidth, height: targetHeight }; - } - - /** Base64-encode a byte buffer in stack-safe chunks (works in Workers via `btoa`). */ - private static toBase64(bytes: Uint8Array): string { - let binary = ""; - const chunkSize = 0x8000; - for (let i = 0; i < bytes.length; i += chunkSize) { - binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize) as any); - } - return btoa(binary); + return { data: Base64.encode(out), width: targetWidth, height: targetHeight }; } } diff --git a/src/shared/capture/bufferDataCapture.ts b/src/shared/capture/bufferDataCapture.ts new file mode 100644 index 00000000..53538340 --- /dev/null +++ b/src/shared/capture/bufferDataCapture.ts @@ -0,0 +1,8 @@ +export interface IBufferDataCapture { + /** Base64 of the captured bytes, truncated to the byte cap when `capped`. */ + data: string; + /** Total byte length even when `data` was truncated. */ + byteLength: number; + capped: boolean; + usage?: string; +} diff --git a/src/shared/capture/capture.ts b/src/shared/capture/capture.ts index f6ebfe7e..ac680853 100644 --- a/src/shared/capture/capture.ts +++ b/src/shared/capture/capture.ts @@ -2,6 +2,7 @@ import { IAnalysis } from "./analysis"; import { ICanvasCapture } from "./canvasCapture"; import { IContextCapture } from "./contextCapture"; import { State, ICommandCapture } from "./commandCapture"; +import { IBufferDataCapture } from "./bufferDataCapture"; export interface ICapture { canvas: ICanvasCapture; @@ -16,4 +17,5 @@ export interface ICapture { analyses: IAnalysis[]; frameMemory: { [objectName: string]: number }; memory: { [objectName: string]: { [second: number]: number } }; + buffers?: { [id: number]: IBufferDataCapture }; } From c2544093f92d061e6a68b1742a0add5b2ca6d1f5 Mon Sep 17 00:00:00 2001 From: Emily-TTG Date: Thu, 17 Sep 2026 13:38:14 +0100 Subject: [PATCH 2/4] test: add tests for WebGL1 buffer upload --- .../backend/recorders/bufferRecorder.test.ts | 123 ++++++++++++++++++ test/unit/backend/utils/base64.test.ts | 34 +++++ 2 files changed, 157 insertions(+) create mode 100644 test/unit/backend/recorders/bufferRecorder.test.ts create mode 100644 test/unit/backend/utils/base64.test.ts diff --git a/test/unit/backend/recorders/bufferRecorder.test.ts b/test/unit/backend/recorders/bufferRecorder.test.ts new file mode 100644 index 00000000..048c8470 --- /dev/null +++ b/test/unit/backend/recorders/bufferRecorder.test.ts @@ -0,0 +1,123 @@ +import { BufferRecorder } from "../../../../src/backend/recorders/bufferRecorder"; +import { WebGlConstants } from "../../../../src/backend/types/webglConstants"; +import { WebGlObjects } from "../../../../src/backend/webGlObjects/baseWebGlObject"; +import { IContextInformation } from "../../../../src/backend/types/contextInformation"; +import { ICapture } from "../../../../src/shared/capture/capture"; + +/** A tagged fake WebGLBuffer plus a context that reports it as the ARRAY_BUFFER binding. */ +function setup(id: number): { recorder: BufferRecorder; buffer: any; call: (fn: any) => void } { + const buffer: any = {}; + WebGlObjects.attachWebGlObjectTag(buffer, { typeName: "WebGLBuffer", id }); + + const context: any = { + getParameter: (parameter: number) => + parameter === WebGlConstants.ARRAY_BUFFER_BINDING.value ? buffer : null, + }; + const options = { + context, + contextVersion: 1, + toggleCapture: () => { /* no-op in tests */ }, + } as unknown as IContextInformation; + + const recorder = new BufferRecorder(options); + // updateWithoutSideEffects is the registered dispatch entry for bufferData/bufferSubData. + const call = (fn: any) => (recorder as any).updateWithoutSideEffects(fn); + return { recorder, buffer, call }; +} + +/** Decode the base64 payload of a captured buffer into a byte array. */ +function decode(base64: string): number[] { + const binary = atob(base64); + const out: number[] = []; + for (let i = 0; i < binary.length; i++) { + out.push(binary.charCodeAt(i)); + } + return out; +} + +/** Flush the recorder into a fresh capture and return its buffers map. */ +function flush(recorder: BufferRecorder): ICapture["buffers"] { + const capture = { frameMemory: {}, memory: {} } as unknown as ICapture; + recorder.appendRecordedInformation(capture); + return capture.buffers; +} + +const bufferData = (data: any, usage = WebGlConstants.STATIC_DRAW.value) => + ({ name: "bufferData", arguments: [WebGlConstants.ARRAY_BUFFER.value, data, usage] }); +const bufferSubData = (offset: number, data: any) => + ({ name: "bufferSubData", arguments: [WebGlConstants.ARRAY_BUFFER.value, offset, data] }); + +describe("BufferRecorder buffer-content capture (#buffer-view)", () => { + afterEach(() => { + BufferRecorder.cap = 4 * 1024 * 1024; + }); + + it("captures the bytes uploaded by bufferData while a capture is armed", () => { + const { recorder, call } = setup(3); + recorder.startCapture(); + call(bufferData(new Uint8Array([1, 2, 3, 4]))); + + const buffers = flush(recorder); + expect(buffers).toBeDefined(); + expect(buffers![3].byteLength).toBe(4); + expect(buffers![3].capped).toBe(false); + expect(buffers![3].usage).toBe("STATIC_DRAW"); + expect(decode(buffers![3].data)).toEqual([1, 2, 3, 4]); + }); + + it("captures nothing when no capture is armed (record-only-while-recording)", () => { + const { recorder, call } = setup(3); + // No startCapture(): capturing is false. + call(bufferData(new Uint8Array([1, 2, 3, 4]))); + expect(flush(recorder)).toBeUndefined(); + }); + + it("clears captured bytes at the start of each armed capture", () => { + const { recorder, call } = setup(3); + recorder.startCapture(); + call(bufferData(new Uint8Array([1, 2, 3, 4]))); + recorder.startCapture(); // arm again — previous frame's bytes must not leak + expect(flush(recorder)).toBeUndefined(); + }); + + it("merges bufferSubData writes into the stored copy at the byte offset", () => { + const { recorder, call } = setup(5); + recorder.startCapture(); + call(bufferData(new Uint8Array([0, 0, 0, 0, 0, 0]))); + call(bufferSubData(2, new Uint8Array([9, 8]))); + + const buffers = flush(recorder); + expect(decode(buffers![5].data)).toEqual([0, 0, 9, 8, 0, 0]); + expect(buffers![5].byteLength).toBe(6); + }); + + it("synthesizes storage for bufferSubData with no prior bufferData this frame", () => { + const { recorder, call } = setup(5); + recorder.startCapture(); + call(bufferSubData(1, new Uint8Array([7, 7]))); + + const buffers = flush(recorder); + expect(buffers![5].byteLength).toBe(3); + expect(decode(buffers![5].data)).toEqual([0, 7, 7]); + }); + + it("truncates buffers larger than the cap but reports the true byteLength", () => { + BufferRecorder.cap = 4; + const { recorder, call } = setup(9); + recorder.startCapture(); + call(bufferData(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))); + + const buffers = flush(recorder); + expect(buffers![9].byteLength).toBe(8); + expect(buffers![9].capped).toBe(true); + expect(decode(buffers![9].data)).toEqual([1, 2, 3, 4]); + }); + + it("drops captured bytes when the buffer is deleted", () => { + const { recorder, buffer, call } = setup(3); + recorder.startCapture(); + call(bufferData(new Uint8Array([1, 2, 3, 4]))); + (recorder as any).deleteWithoutSideEffects({ name: "deleteBuffer", arguments: [buffer] }); + expect(flush(recorder)).toBeUndefined(); + }); +}); diff --git a/test/unit/backend/utils/base64.test.ts b/test/unit/backend/utils/base64.test.ts new file mode 100644 index 00000000..f2e9c3ad --- /dev/null +++ b/test/unit/backend/utils/base64.test.ts @@ -0,0 +1,34 @@ +import { Base64 } from "../../../../src/backend/utils/base64"; + +/** Decode a base64 string back into a byte array for assertions. */ +function decode(base64: string): number[] { + const binary = atob(base64); + const out: number[] = []; + for (let i = 0; i < binary.length; i++) { + out.push(binary.charCodeAt(i)); + } + return out; +} + +describe("Base64.encode", () => { + it("round-trips a small byte buffer", () => { + const bytes = new Uint8Array([0, 1, 2, 254, 255, 128]); + expect(decode(Base64.encode(bytes))).toEqual(Array.from(bytes)); + }); + + it("returns an empty string for an empty buffer", () => { + expect(Base64.encode(new Uint8Array(0))).toBe(""); + }); + + it("encodes across the 0x8000 chunk boundary without truncation", () => { + // Larger than one chunk so the stack-safe chunking path is exercised. + const bytes = new Uint8Array(0x8000 * 2 + 123); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = i & 0xff; + } + const decoded = decode(Base64.encode(bytes)); + expect(decoded.length).toBe(bytes.length); + expect(decoded[0]).toBe(0); + expect(decoded[bytes.length - 1]).toBe((bytes.length - 1) & 0xff); + }); +}); From 78eb8995bd7404b8a25c404c735c418a1ff44b3b Mon Sep 17 00:00:00 2001 From: Emily-TTG Date: Thu, 17 Sep 2026 14:05:31 +0100 Subject: [PATCH 3/4] feat: add buffer data view to tree view --- .../BufferViewer/BufferViewerModal.tsx | 76 ++++++ .../react/ResultView/JSON/JSONBufferItem.tsx | 33 +++ .../react/ResultView/JSON/JSONRenderTree.tsx | 3 + .../react/ResultView/ReactResultView.ts | 22 +- .../react/ResultView/ResultViewRoot.tsx | 2 + .../react/ResultView/jsonTreeBuilder.ts | 75 +++++- .../react/shared/bufferView.ts | 109 +++++++++ src/embeddedFrontend/react/shared/types.ts | 28 ++- src/embeddedFrontend/styles/resultView.scss | 229 ++++++++++++------ 9 files changed, 498 insertions(+), 79 deletions(-) create mode 100644 src/embeddedFrontend/react/ResultView/BufferViewer/BufferViewerModal.tsx create mode 100644 src/embeddedFrontend/react/ResultView/JSON/JSONBufferItem.tsx create mode 100644 src/embeddedFrontend/react/shared/bufferView.ts diff --git a/src/embeddedFrontend/react/ResultView/BufferViewer/BufferViewerModal.tsx b/src/embeddedFrontend/react/ResultView/BufferViewer/BufferViewerModal.tsx new file mode 100644 index 00000000..85bcc540 --- /dev/null +++ b/src/embeddedFrontend/react/ResultView/BufferViewer/BufferViewerModal.tsx @@ -0,0 +1,76 @@ +import React, { useEffect, useMemo } from "react"; +import { useStore } from "../../shared/ExternalStore"; +import { useResultView } from "../ResultViewContext"; +import { IBufferViewerState } from "../../shared/types"; +import { IBufferDataCapture } from "../../../../shared/capture/bufferDataCapture"; +import { decodeBase64Bytes, decodeVertexRows, decodeIndices, IDecodedRows } from "../../shared/bufferView"; + +const MAX_ROWS = 2000; + +export function BufferViewerModal() { + const adapter = useResultView(); + const state = useStore(adapter.store); + const viewer = state.bufferViewer; + if (!viewer.open) { + return null; + } + const captured = state.currentCapture?.buffers?.[viewer.bufferId]; + return ; +} + +function BufferViewerContent(props: { viewer: IBufferViewerState; captured?: IBufferDataCapture; onClose: () => void }) { + const { viewer, captured, onClose } = props; + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { onClose(); } }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const decoded = useMemo(() => { + if (!captured || !viewer.layout) { return null; } + const bytes = decodeBase64Bytes(captured.data); + return viewer.layout.kind === "index" + ? decodeIndices(bytes, viewer.layout.indexType, MAX_ROWS) + : decodeVertexRows(bytes, viewer.layout, MAX_ROWS); + }, [captured, viewer.layout]); + + return ( +
+
e.stopPropagation()}> +
+ {viewer.label} + + {captured ? captured.byteLength + " bytes" : "not captured"} + {captured && captured.capped ? " · truncated" : ""} + + ✕ +
+
+ {!captured &&
This buffer was not captured this frame.
} + {captured && decoded && } +
+
+
+ ); +} + +function BufferTable({ decoded }: { decoded: IDecodedRows }) { + return ( + + + {decoded.header.map((h, i) => )} + + + {decoded.rows.map((row, r) => ( + {row.map((cell, c) => )} + ))} + {decoded.truncated && ( + + + + )} + +
{h}
{cell}
Showing first {decoded.rows.length} rows.
+ ); +} diff --git a/src/embeddedFrontend/react/ResultView/JSON/JSONBufferItem.tsx b/src/embeddedFrontend/react/ResultView/JSON/JSONBufferItem.tsx new file mode 100644 index 00000000..edbdcfba --- /dev/null +++ b/src/embeddedFrontend/react/ResultView/JSON/JSONBufferItem.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { useResultView } from "../ResultViewContext"; +import { IBufferLayout } from "../../shared/types"; + +/** + * "View buffer" affordance in the JSON view. Opens the buffer + * viewer modal for a captured vertex/index buffer. + * + * DOM: + *
  • + * View buffer + * label + *
  • + */ +export interface JSONBufferItemProps { + label: string; + bufferId: number; + layout: IBufferLayout; +} + +export function JSONBufferItem({ label, bufferId, layout }: JSONBufferItemProps) { + const adapter = useResultView(); + return ( +
  • + adapter.openBufferViewer({ label, bufferId, layout })} + >View buffer + {label} +
  • + ); +} diff --git a/src/embeddedFrontend/react/ResultView/JSON/JSONRenderTree.tsx b/src/embeddedFrontend/react/ResultView/JSON/JSONRenderTree.tsx index 8a2aad3a..95428190 100644 --- a/src/embeddedFrontend/react/ResultView/JSON/JSONRenderTree.tsx +++ b/src/embeddedFrontend/react/ResultView/JSON/JSONRenderTree.tsx @@ -6,6 +6,7 @@ import { JSONImageItem } from "./JSONImageItem"; import { JSONHelpItem } from "./JSONHelpItem"; import { JSONVisualStateItem } from "./JSONVisualStateItem"; import { JSONShaderSourceItem } from "./JSONShaderSourceItem"; +import { JSONBufferItem } from "./JSONBufferItem"; import { IShaderCapture } from "../../../../shared/capture/programCapture"; /** @@ -49,6 +50,8 @@ export function JSONRenderTree({ items, onShaderSourceOpen }: JSONRenderTreeProp ); case "visualState": return ; + case "buffer": + return ; default: return null; } diff --git a/src/embeddedFrontend/react/ResultView/ReactResultView.ts b/src/embeddedFrontend/react/ResultView/ReactResultView.ts index 71044c7b..3bc253d6 100644 --- a/src/embeddedFrontend/react/ResultView/ReactResultView.ts +++ b/src/embeddedFrontend/react/ResultView/ReactResultView.ts @@ -3,6 +3,7 @@ import { createRoot, Root } from "react-dom/client"; import { createElement } from "react"; import { Observable } from "../../../shared/utils/observable"; import { ICapture } from "../../../shared/capture/capture"; +import { IBufferDataCapture } from "../../../shared/capture/bufferDataCapture"; import { ICommandCapture } from "../../../shared/capture/commandCapture"; import { IShaderCapture } from "../../../shared/capture/programCapture"; import { ExternalStore } from "../shared/ExternalStore"; @@ -15,6 +16,7 @@ import { ResultViewState, JSONRenderItem, IRawImagePixels, + IBufferLayout, } from "../shared/types"; import { ResultViewRoot } from "./ResultViewRoot"; import { ResultViewContext } from "./ResultViewContext"; @@ -48,6 +50,7 @@ const EMPTY_STATE: ResultViewState = { canCompare: false, compareLabel: "", textureViewer: { open: false, src: "", label: "", pixelated: false, raw: null }, + bufferViewer: { open: false, label: "", bufferId: -1, layout: null }, }; // ─── Adapter class ────────────────────────────────────────────────────────── @@ -320,6 +323,19 @@ export class ReactResultView { this.store.setState((prev) => ({ ...prev, textureViewer: { ...prev.textureViewer, open: false } })); } + /** Open the buffer viewer modal for a captured vertex/index buffer. */ + public openBufferViewer = (payload: { label: string; bufferId: number; layout: IBufferLayout }): void => { + this.store.setState((prev) => ({ + ...prev, + bufferViewer: { open: true, label: payload.label, bufferId: payload.bufferId, layout: payload.layout }, + })); + } + + /** Close the buffer viewer modal. */ + public closeBufferViewer = (): void => { + this.store.setState((prev) => ({ ...prev, bufferViewer: { ...prev.bufferViewer, open: false } })); + } + /** Called by React when user selects a visual state. */ public handleVisualStateSelected = (visualStateIndex: number): void => { this.selectVisualState(visualStateIndex); @@ -633,7 +649,7 @@ export class ReactResultView { // Build command detail for the selected command let commandDetailData: JSONRenderItem[] = []; if (autoSelectCommandIdx >= 0) { - commandDetailData = this._buildCommandDetail(autoSelectCommandIdx, commands, visualStates); + commandDetailData = this._buildCommandDetail(autoSelectCommandIdx, commands, visualStates, capture.buffers); } this.store.setState((prev) => ({ @@ -729,12 +745,14 @@ export class ReactResultView { commandIndex: number, commands: ICommandListItemState[], visualStates: IVisualStateItem[], + buffers?: { [id: number]: IBufferDataCapture }, ): JSONRenderItem[] { if (commandIndex < 0 || commandIndex >= commands.length) { return []; } const cmd = commands[commandIndex]; const vs = visualStates[cmd.visualStateIndex]; const resolved = this._resolvedStackTraces.get(cmd.capture.id); - const detail = buildCommandDetail(cmd.capture, vs?.VisualState, resolved); + const captureBuffers = buffers ?? this.store.getSnapshot().currentCapture?.buffers; + const detail = buildCommandDetail(cmd.capture, vs?.VisualState, resolved, captureBuffers); if (!resolved) { this._resolveStackTraceAsync(cmd.capture); } diff --git a/src/embeddedFrontend/react/ResultView/ResultViewRoot.tsx b/src/embeddedFrontend/react/ResultView/ResultViewRoot.tsx index 1b278b77..7bdb8a3b 100644 --- a/src/embeddedFrontend/react/ResultView/ResultViewRoot.tsx +++ b/src/embeddedFrontend/react/ResultView/ResultViewRoot.tsx @@ -15,6 +15,7 @@ import { SourceCode } from "./SourceCode/SourceCode"; import { JSONRenderTree } from "./JSON/JSONRenderTree"; import { CompareView } from "./Compare/CompareView"; import { TextureViewerModal } from "./TextureViewer/TextureViewerModal"; +import { BufferViewerModal } from "./BufferViewer/BufferViewerModal"; import { MenuStatus, JSONRenderItem } from "../shared/types"; /** @@ -129,6 +130,7 @@ export function ResultViewRoot() { )} + ); } diff --git a/src/embeddedFrontend/react/ResultView/jsonTreeBuilder.ts b/src/embeddedFrontend/react/ResultView/jsonTreeBuilder.ts index 59c25189..47607fa4 100644 --- a/src/embeddedFrontend/react/ResultView/jsonTreeBuilder.ts +++ b/src/embeddedFrontend/react/ResultView/jsonTreeBuilder.ts @@ -1,8 +1,21 @@ import { ICommandCapture, CommandCaptureStatus } from "../../../shared/capture/commandCapture"; +import { IBufferDataCapture } from "../../../shared/capture/bufferDataCapture"; import { JSONRenderItem, IRawImagePixels } from "../shared/types"; import { MDNCommandLinkHelper } from "../shared/mdnCommandLinkHelper"; import { WebGLParameterNameHelper } from "../shared/webglParameterNameHelper"; +/** Captured buffers for the current command, plus the resolved index type. */ +interface IBufferTreeContext { + buffers: { [id: number]: IBufferDataCapture }; + indexType?: string; +} + +const INDEX_TYPE_NAMES: { [value: number]: string } = { + 5121: "UNSIGNED_BYTE", + 5123: "UNSIGNED_SHORT", + 5125: "UNSIGNED_INT", +}; + /** * Pure builders that turn captured GL state (init/end state, command detail, * information columns) into the {@link JSONRenderItem} tree the React JSON @@ -34,6 +47,7 @@ function getJSONAsString( key: string, json: any, searchText: string, + bufferCtx?: IBufferTreeContext, ): string | null { if (json === null) { return "null"; } if (json === undefined) { return "undefined"; } @@ -49,7 +63,7 @@ function getJSONAsString( if (json.length) { const arrayResult: string[] = []; for (let i = 0; i < json.length; i++) { - const resultItem = getJSONAsString(parentChildren, `${key}(${i.toFixed(0)})`, json[i], searchText); + const resultItem = getJSONAsString(parentChildren, `${key}(${i.toFixed(0)})`, json[i], searchText, bufferCtx); if (resultItem !== null) { arrayResult.push(resultItem); } @@ -71,12 +85,43 @@ function getJSONAsString( } if (typeof json === "object") { - buildJSONGroup(parentChildren, key, json, searchText); + buildJSONGroup(parentChildren, key, json, searchText, bufferCtx); } return null; } +/** Emit a "View buffer" item when this object references a captured vertex/index buffer. */ +function appendBufferItem(parentChildren: JSONRenderItem[], json: any, bufferCtx: IBufferTreeContext): void { + const vertexTag = json.bufferBinding && json.bufferBinding.__SPECTOR_Object_TAG; + if (vertexTag && bufferCtx.buffers[vertexTag.id]) { + parentChildren.push({ + type: "buffer", + label: json.name || "buffer", + bufferId: vertexTag.id, + layout: { + kind: "vertex", + componentType: json.arrayType, + components: json.arraySize, + stride: json.stride, + offset: json.offsetPointer, + normalized: json.normalized, + }, + }); + return; + } + + const indexTag = json.arrayBuffer && json.arrayBuffer.__SPECTOR_Object_TAG; + if (indexTag && bufferCtx.buffers[indexTag.id]) { + parentChildren.push({ + type: "buffer", + label: "element array", + bufferId: indexTag.id, + layout: { kind: "index", indexType: bufferCtx.indexType }, + }); + } +} + /** Non-premultiplied raw pixels for a given `visual` target, when captured (#183). */ function rawPixelsFor(json: any, target: string): IRawImagePixels | undefined { const map = json.visualPixels; @@ -99,11 +144,15 @@ function buildImageItems(parentChildren: JSONRenderItem[], json: any, value: any } } -export function buildJSON(parentChildren: JSONRenderItem[], json: any, searchText: string): void { +export function buildJSON(parentChildren: JSONRenderItem[], json: any, searchText: string, bufferCtx?: IBufferTreeContext): void { if (json.VisualState) { parentChildren.push({ type: "visualState", visualState: json.VisualState }); } + if (bufferCtx) { + appendBufferItem(parentChildren, json, bufferCtx); + } + for (const key in json) { if (SKIP_KEYS[key]) { continue; @@ -113,7 +162,7 @@ export function buildJSON(parentChildren: JSONRenderItem[], json: any, searchTex if (key === "visual") { buildImageItems(parentChildren, json, value); } else { - const result = getJSONAsString(parentChildren, key, value, searchText); + const result = getJSONAsString(parentChildren, key, value, searchText, bufferCtx); if (result === null || result === undefined) { continue; } else if (toFilter(key, searchText) && toFilter(value, searchText)) { @@ -134,24 +183,38 @@ export function buildJSONGroup( title: string, json: any, searchText: string, + bufferCtx?: IBufferTreeContext, ): void { if (!json) { return; } const children: JSONRenderItem[] = []; - buildJSON(children, json, searchText); + buildJSON(children, json, searchText, bufferCtx); if (children.length === 0) { return; } parentChildren.push({ type: "group", title, children }); } +/** Resolve the index type name of a drawElements call from its arguments. */ +function resolveIndexType(command: ICommandCapture): string | undefined { + const args = command.commandArguments as any; + if (!args || command.name.indexOf("Elements") < 0) { + return undefined; + } + const names = WebGLParameterNameHelper.getNames(command.name, args.length); + const typeIndex = names ? names.indexOf("type") : -1; + return typeIndex >= 0 ? INDEX_TYPE_NAMES[args[typeIndex]] : undefined; +} + // ─── Command detail builder ────────────────────────────────────────────────── export function buildCommandDetail( command: ICommandCapture, visualState: any, resolvedStackTrace?: string[], + buffers?: { [id: number]: IBufferDataCapture }, ): JSONRenderItem[] { const items: JSONRenderItem[] = []; + const bufferCtx = buffers ? { buffers, indexType: resolveIndexType(command) } : undefined; // Visual state thumbnail at top if (visualState) { @@ -204,7 +267,7 @@ export function buildCommandDetail( continue; } if (typeof command[key] === "object") { - buildJSONGroup(items, key, command[key], ""); + buildJSONGroup(items, key, command[key], "", bufferCtx); } } diff --git a/src/embeddedFrontend/react/shared/bufferView.ts b/src/embeddedFrontend/react/shared/bufferView.ts new file mode 100644 index 00000000..ef7ac207 --- /dev/null +++ b/src/embeddedFrontend/react/shared/bufferView.ts @@ -0,0 +1,109 @@ +import { IBufferLayout } from "./types"; + +export interface IDecodedRows { + header: string[]; + rows: string[][]; + truncated: boolean; +} + +interface IComponentType { + ctor: new (buffer: ArrayBuffer) => ArrayLike; + size: number; + max: number; + half?: boolean; +} + +const COMPONENT_TYPES: { [name: string]: IComponentType } = { + BYTE: { ctor: Int8Array, size: 1, max: 127 }, + UNSIGNED_BYTE: { ctor: Uint8Array, size: 1, max: 255 }, + SHORT: { ctor: Int16Array, size: 2, max: 32767 }, + UNSIGNED_SHORT: { ctor: Uint16Array, size: 2, max: 65535 }, + INT: { ctor: Int32Array, size: 4, max: 2147483647 }, + UNSIGNED_INT: { ctor: Uint32Array, size: 4, max: 4294967295 }, + FLOAT: { ctor: Float32Array, size: 4, max: 1 }, + HALF_FLOAT: { ctor: Uint16Array, size: 2, max: 1, half: true }, +}; + +export function decodeBase64Bytes(base64: string): Uint8Array { + const binary = atob(base64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + out[i] = binary.charCodeAt(i); + } + return out; +} + +function decodeHalf(bits: number): number { + const exponent = (bits & 0x7c00) >> 10; + const fraction = bits & 0x03ff; + const sign = (bits & 0x8000) ? -1 : 1; + if (exponent === 0) { + return sign * Math.pow(2, -14) * (fraction / 1024); + } + if (exponent === 0x1f) { + return fraction ? NaN : sign * Infinity; + } + return sign * Math.pow(2, exponent - 15) * (1 + fraction / 1024); +} + +function format(value: number): string { + return Number.isInteger(value) ? value.toFixed(0) : value.toFixed(4); +} + +export function decodeVertexRows(bytes: Uint8Array, layout: IBufferLayout, maxRows: number): IDecodedRows { + const type = COMPONENT_TYPES[layout.componentType] || COMPONENT_TYPES.FLOAT; + const components = layout.components || 1; + const offset = layout.offset || 0; + const stride = layout.stride || components * type.size; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + const available = Math.floor((bytes.length - offset - components * type.size) / stride) + 1; + const count = Math.max(0, Math.min(available, maxRows)); + + const header = ["#"]; + for (let c = 0; c < components; c++) { + header.push(String.fromCharCode(120 + c)); // x, y, z, w + } + + const rows: string[][] = []; + for (let r = 0; r < count; r++) { + const row = [r.toFixed(0)]; + for (let c = 0; c < components; c++) { + let value = readComponent(view, offset + r * stride + c * type.size, type); + if (layout.normalized && !type.half && type.max !== 1) { + value = type.max === 255 || type.max === 65535 || type.max === 4294967295 + ? value / type.max + : Math.max(value / type.max, -1); + } + row.push(format(value)); + } + rows.push(row); + } + return { header, rows, truncated: available > count }; +} + +function readComponent(view: DataView, byteOffset: number, type: IComponentType): number { + switch (type) { + case COMPONENT_TYPES.BYTE: return view.getInt8(byteOffset); + case COMPONENT_TYPES.UNSIGNED_BYTE: return view.getUint8(byteOffset); + case COMPONENT_TYPES.SHORT: return view.getInt16(byteOffset, true); + case COMPONENT_TYPES.UNSIGNED_SHORT: return view.getUint16(byteOffset, true); + case COMPONENT_TYPES.INT: return view.getInt32(byteOffset, true); + case COMPONENT_TYPES.UNSIGNED_INT: return view.getUint32(byteOffset, true); + case COMPONENT_TYPES.HALF_FLOAT: return decodeHalf(view.getUint16(byteOffset, true)); + default: return view.getFloat32(byteOffset, true); + } +} + +export function decodeIndices(bytes: Uint8Array, indexType: string, maxRows: number): IDecodedRows { + const type = COMPONENT_TYPES[indexType] || COMPONENT_TYPES.UNSIGNED_SHORT; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const available = Math.floor(bytes.length / type.size); + const count = Math.max(0, Math.min(available, maxRows)); + + const rows: string[][] = []; + for (let i = 0; i < count; i++) { + rows.push([i.toFixed(0), readComponent(view, i * type.size, type).toFixed(0)]); + } + return { header: ["#", "index"], rows, truncated: available > count }; +} diff --git a/src/embeddedFrontend/react/shared/types.ts b/src/embeddedFrontend/react/shared/types.ts index 97a95536..ad4fa87c 100644 --- a/src/embeddedFrontend/react/shared/types.ts +++ b/src/embeddedFrontend/react/shared/types.ts @@ -104,7 +104,8 @@ export type JSONRenderItem = | { type: "image"; key: string; value: string; pixelated: boolean; raw?: IRawImagePixels } | { type: "help"; key: string; value: string; help: string } | { type: "shaderSource"; key: string; shader: IShaderCapture; programLog?: string } - | { type: "visualState"; visualState: any }; + | { type: "visualState"; visualState: any } + | { type: "buffer"; label: string; bufferId: number; layout: IBufferLayout }; // ─── Texture viewer (#183) ─────────────────────────────────────────────────── @@ -122,6 +123,29 @@ export interface ITextureViewerState { raw: IRawImagePixels | null; } +// ─── Buffer viewer ──────────────────────────────────────────── + +/** How to decode a captured buffer's bytes for display. */ +export interface IBufferLayout { + kind: "vertex" | "index"; + /** Vertex: GL component type name (e.g. "FLOAT"), component count, and byte stride/offset. */ + componentType?: string; + components?: number; + stride?: number; + offset?: number; + normalized?: boolean; + /** Index: element type name (e.g. "UNSIGNED_SHORT"). */ + indexType?: string; +} + +/** State of the buffer viewer modal. */ +export interface IBufferViewerState { + open: boolean; + label: string; + bufferId: number; + layout: IBufferLayout | null; +} + // ─── ResultView state ──────────────────────────────────────────────────────── export interface ResultViewState { @@ -153,4 +177,6 @@ export interface ResultViewState { compareLabel: string; // Texture viewer (#183): full-screen channel/alpha/pixel inspector modal textureViewer: ITextureViewerState; + // Buffer viewer: decoded vertex/index buffer contents modal + bufferViewer: IBufferViewerState; } diff --git a/src/embeddedFrontend/styles/resultView.scss b/src/embeddedFrontend/styles/resultView.scss index e45f7fe0..2962cb84 100644 --- a/src/embeddedFrontend/styles/resultView.scss +++ b/src/embeddedFrontend/styles/resultView.scss @@ -2,23 +2,23 @@ /* The main window */ .resultViewComponent { - position:absolute; - z-index: 99999; - border : 1px solid darken($background, 20%); + position:absolute; + z-index: 99999; + border : 1px solid darken($background, 20%); top: 0; - left: 0; + left: 0; bottom: 0; right: 0; - background-color: $background; + background-color: $background; opacity: 1; visibility: hidden; display: none; color: $foreground; - font-family: Consolas, monaco, monospace; + font-family: Consolas, monaco, monospace; font-size: 14px; font-weight: 500; - + &.active { visibility: visible; display: block; @@ -35,7 +35,7 @@ $menuHeight: $menuBaseHeight * 2; .resultViewMenuComponent { font-family: sans-serif; - // text-transform: uppercase; + // text-transform: uppercase; font-size: 13px; font-weight: 300; line-height: $menuHeight; @@ -44,19 +44,19 @@ $menuHeight: $menuBaseHeight * 2; flex-flow: row wrap; height: $menuHeight + $border; outline: 0 none; - border-bottom: $border solid $background; + border-bottom: $border solid $background; box-sizing: border-box; list-style: none; - margin: 0; - + margin: 0; + background: $background-even; - + display: -webkit-box; display: -moz-box; display: -ms-flexbox; display: -webkit-flex; display: flex; - + -webkit-flex-flow: row wrap; flex-flow: row wrap; justify-content: flex-end; @@ -85,7 +85,7 @@ $menuHeight: $menuBaseHeight * 2; &:hover { background: $background; - color: $foreground-focus; + color: $foreground-focus; cursor: pointer; transition: color $transition-time; -webkit-transition: color $transition-time; @@ -96,20 +96,20 @@ $menuHeight: $menuBaseHeight * 2; transition: color 0; -webkit-transition: color 0; -moz-transition: color 0; - } + } } &.clearSearch { // display: inline-block; - padding: 0px; + padding: 0px; margin-left: -30px; margin-right: 20px; z-index: 9000; - color: $foreground; - + color: $foreground; + &:hover { background: $background-even; - color: $foreground-important; + color: $foreground-important; } } } @@ -131,7 +131,7 @@ $menuHeight: $menuBaseHeight * 2; li.searchContainer { background: lighten($background-even, 10%); } - + padding: 0px; position: absolute; overflow-y: visible; @@ -157,7 +157,7 @@ $menuHeight: $menuBaseHeight * 2; // padding: $margin; background: lighten($background-even, 10%); color: $foreground; - height: $menuHeight; + height: $menuHeight; position:relative; top:-1px; // because of ul.border box-sizing: border-box; @@ -178,7 +178,7 @@ $menuHeight: $menuBaseHeight * 2; margin-left: -30px; z-index: 9000; color: $foreground-important; - + &:hover { background:transparent !important; } @@ -188,20 +188,20 @@ $menuHeight: $menuBaseHeight * 2; color: darken(white, 20%); } :-moz-placeholder { /* Mozilla Firefox 4 to 18 */ - color: darken(white, 20%); + color: darken(white, 20%); } ::-moz-placeholder { /* Mozilla Firefox 19+ */ - color: darken(white, 20%); + color: darken(white, 20%); } :-ms-input-placeholder { /* Internet Explorer 10-11 */ - color: darken(white, 20%); + color: darken(white, 20%); } } .resultViewContentComponent { - position:absolute; + position:absolute; top: $menuHeight; - left: 0; + left: 0; bottom: 0; right: 0; } @@ -218,7 +218,7 @@ $menuHeight: $menuBaseHeight * 2; } .informationColumnRightComponent { - position:absolute; + position:absolute; top: 0; left: 50%; bottom: 0; @@ -229,15 +229,15 @@ $menuHeight: $menuBaseHeight * 2; } .captureListComponent { - position:absolute; + position:absolute; top: $menuHeight; - left: 0; + left: 0; bottom: 0; right: 0; background: $background; z-index: 9000; display: none; - visibility: hidden; + visibility: hidden; overflow-y: visible; overflow-x: hidden; @@ -253,7 +253,7 @@ $menuHeight: $menuBaseHeight * 2; padding: $margin; text-align: center; font-style: italic; - + span { line-height: 100%; vertical-align: middle; @@ -268,7 +268,7 @@ $menuHeight: $menuBaseHeight * 2; display: -moz-box; display: -ms-flexbox; display: -webkit-flex; - display: flex; + display: flex; -webkit-flex-flow: row wrap; flex-flow: row wrap; justify-content: flex-start; @@ -294,7 +294,7 @@ $menuHeight: $menuBaseHeight * 2; margin-left: 10px; position: relative; padding: 3px 8px 3px 32px; - + &:before, &:after { box-sizing: border-box; content: ""; @@ -349,7 +349,7 @@ $menuHeight: $menuBaseHeight * 2; $visualStateListComponentWidth: 20%; .visualStateListComponent { - position:absolute; + position:absolute; top: 0; left: 0; bottom: 0; @@ -363,7 +363,7 @@ $visualStateListComponentWidth: 20%; padding: 0px; list-style: none; li { - margin: 20px 15px 0px 15px; + margin: 20px 15px 0px 15px; border : 1px solid darken($foreground, 60%); img { @@ -405,12 +405,12 @@ $visualStateListComponentWidth: 20%; $commandDetailComponentWidth: 40%; .commandListComponent { - position:absolute; + position:absolute; top: 0; - left: $visualStateListComponentWidth; + left: $visualStateListComponentWidth; right: $commandDetailComponentWidth; bottom: 0; - color:darken($foreground, 15%); + color:darken($foreground, 15%); ul { margin: 0px; @@ -436,7 +436,7 @@ $commandDetailComponentWidth: 40%; background: $background-even; } - &:nth-child(odd) { + &:nth-child(odd) { background: $background; } @@ -468,7 +468,7 @@ $commandDetailComponentWidth: 40%; } &.active { - background: lighten($selected, 6%); + background: lighten($selected, 6%); color:#222; } @@ -491,9 +491,9 @@ $commandDetailComponentWidth: 40%; } .commandDetailComponent { - position:absolute; + position:absolute; top: 0; - left: 100% - $commandDetailComponentWidth; + left: 100% - $commandDetailComponentWidth; right: 0; bottom: 0; overflow-y: visible; @@ -526,10 +526,10 @@ $commandDetailComponentWidth: 40%; background: $background; } - &:nth-child(odd) { + &:nth-child(odd) { background: $background; } - } + } } } @@ -595,19 +595,19 @@ $commandDetailComponentWidth: 40%; } .jsonContentComponent { - position:absolute; + position:absolute; top: 0; - left: 0; + left: 0; right: 0; bottom: 0; - padding:10px; + padding:10px; overflow-y: visible; overflow-x: hidden; } .jsonItemComponentValue { word-break: break-all; - white-space: normal; + white-space: normal; } .jsonSourceItemComponentOpen { @@ -649,7 +649,7 @@ $commandDetailComponentWidth: 40%; .sourceCodeMenuComponent { font-family: sans-serif; - // text-transform: uppercase; + // text-transform: uppercase; font-size: 13px; font-weight: 300; line-height: $menuHeight; @@ -658,19 +658,19 @@ $commandDetailComponentWidth: 40%; flex-flow: row wrap; height: $menuHeight + $border; outline: 0 none; - border-bottom: $border solid $background; + border-bottom: $border solid $background; box-sizing: border-box; list-style: none; - margin: 0; - + margin: 0; + background: $background-even; - + display: -webkit-box; display: -moz-box; display: -ms-flexbox; display: -webkit-flex; display: flex; - + -webkit-flex-flow: row wrap; flex-flow: row wrap; justify-content: flex-end; @@ -688,7 +688,7 @@ $commandDetailComponentWidth: 40%; color: darken(white, 20%); background: $background-even; box-sizing: border-box; - height:100%; + height:100%; &.active { background: $background; @@ -699,7 +699,7 @@ $commandDetailComponentWidth: 40%; &:hover { background: $background; - color: $foreground-focus; + color: $foreground-focus; cursor: pointer; transition: color $transition-time; -webkit-transition: color $transition-time; @@ -710,20 +710,20 @@ $commandDetailComponentWidth: 40%; transition: color 0; -webkit-transition: color 0; -moz-transition: color 0; - } + } } &.clearSearch { display: inline-block; - padding: 0px; + padding: 0px; margin-left: -30px; margin-right: 20px; z-index: 9000; - color: $foreground; - + color: $foreground; + &:hover { background: $background-even; - color: $foreground-important; + color: $foreground-important; } } } @@ -738,7 +738,7 @@ $commandDetailComponentWidth: 40%; // padding: $margin; background: lighten($background-even, 10%); color: $foreground; - height:100%; + height:100%; position:relative; top:-1px; // because of ul.border box-sizing: border-box; @@ -759,7 +759,7 @@ $commandDetailComponentWidth: 40%; margin-left: -30px; z-index: 9000; color: $foreground-important; - + &:hover { background:transparent !important; } @@ -769,25 +769,25 @@ $commandDetailComponentWidth: 40%; color: darken(white, 20%); } :-moz-placeholder { /* Mozilla Firefox 4 to 18 */ - color: darken(white, 20%); + color: darken(white, 20%); } ::-moz-placeholder { /* Mozilla Firefox 19+ */ - color: darken(white, 20%); + color: darken(white, 20%); } :-ms-input-placeholder { /* Internet Explorer 10-11 */ - color: darken(white, 20%); + color: darken(white, 20%); } } .sourceCodeComponent { - position:absolute; + position:absolute; top: $menuHeight + $border; - left: 0; + left: 0; bottom: 48px; right: $commandDetailComponentWidth; background: $background; z-index: 9000; - + overflow-x: visible; overflow: auto; @@ -1273,3 +1273,92 @@ $commandDetailComponentWidth: 40%; .hint { color: $foreground-focus; margin-top: 10px; line-height: 1.5; } } } + +/* Buffer viewer: decoded vertex/index buffer table. */ +.jsonItemBuffer { + .jsonItemBufferButton { + cursor: pointer; + color: $foreground-title; + text-decoration: underline; + margin-right: 6px; + } +} + +.bufferViewerOverlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 100000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); + font-family: Consolas, monaco, monospace; + font-size: 13px; + color: $foreground; + + .bufferViewerModal { + width: min(720px, 92vw); + height: min(640px, 90vh); + display: flex; + flex-direction: column; + overflow: hidden; + background: $background; + border: 1px solid #000; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6); + } + + .bufferViewerHeader { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 12px; + height: 40px; + padding: 0 12px; + background: $background-even; + border-bottom: 2px solid $background; + + .bufferViewerTitle { color: $foreground-title; } + .bufferViewerMeta { color: $foreground-focus; font-size: 12px; } + .bufferViewerClose { + margin-left: auto; + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 3px; + color: $foreground-focus; + cursor: pointer; + + &:hover { color: $foreground; background: rgba(255, 255, 255, 0.08); } + } + } + + .bufferViewerBody { + flex: 1 1 auto; + overflow: auto; + padding: 8px 12px; + } + + .bufferViewerEmpty { color: $foreground-focus; } + + .bufferViewerTable { + border-collapse: collapse; + width: 100%; + + th, td { + text-align: right; + padding: 2px 10px; + border-bottom: 1px solid $background-even; + white-space: nowrap; + } + + th { color: $foreground-title; position: sticky; top: 0; background: $background; } + td:first-child, th:first-child { text-align: left; color: $foreground-focus; } + + .bufferViewerMore td { text-align: center; color: $foreground-focus; } + } +} From 628de02f8656fd178759a3545c942da74639e924 Mon Sep 17 00:00:00 2001 From: Emily-TTG Date: Thu, 17 Sep 2026 14:06:42 +0100 Subject: [PATCH 4/4] feat: add buffer view tests --- .../backend/recorders/bufferRecorder.test.ts | 2 +- test/unit/embeddedFrontend/bufferView.test.ts | 60 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 test/unit/embeddedFrontend/bufferView.test.ts diff --git a/test/unit/backend/recorders/bufferRecorder.test.ts b/test/unit/backend/recorders/bufferRecorder.test.ts index 048c8470..794afdce 100644 --- a/test/unit/backend/recorders/bufferRecorder.test.ts +++ b/test/unit/backend/recorders/bufferRecorder.test.ts @@ -47,7 +47,7 @@ const bufferData = (data: any, usage = WebGlConstants.STATIC_DRAW.value) => const bufferSubData = (offset: number, data: any) => ({ name: "bufferSubData", arguments: [WebGlConstants.ARRAY_BUFFER.value, offset, data] }); -describe("BufferRecorder buffer-content capture (#buffer-view)", () => { +describe("BufferRecorder buffer-content capture", () => { afterEach(() => { BufferRecorder.cap = 4 * 1024 * 1024; }); diff --git a/test/unit/embeddedFrontend/bufferView.test.ts b/test/unit/embeddedFrontend/bufferView.test.ts new file mode 100644 index 00000000..961677ca --- /dev/null +++ b/test/unit/embeddedFrontend/bufferView.test.ts @@ -0,0 +1,60 @@ +import { decodeBase64Bytes, decodeVertexRows, decodeIndices } from "../../../src/embeddedFrontend/react/shared/bufferView"; +import { IBufferLayout } from "../../../src/embeddedFrontend/react/shared/types"; + +function encode(bytes: Uint8Array): string { + return btoa(String.fromCharCode.apply(null, Array.from(bytes) as any)); +} + +function floats(values: number[]): Uint8Array { + return new Uint8Array(new Float32Array(values).buffer); +} + +describe("bufferView.decodeBase64Bytes", () => { + it("round-trips bytes and exposes a reinterpretable buffer", () => { + const bytes = decodeBase64Bytes(encode(floats([1.5, -2.25]))); + expect(Array.from(new Float32Array(bytes.buffer))).toEqual([1.5, -2.25]); + }); +}); + +describe("bufferView.decodeVertexRows", () => { + const vec2: IBufferLayout = { kind: "vertex", componentType: "FLOAT", components: 2, stride: 0, offset: 0 }; + + it("lays out tightly-packed float vec2 rows", () => { + const bytes = decodeBase64Bytes(encode(floats([0, 1, 2, 3]))); + const decoded = decodeVertexRows(bytes, vec2, 100); + expect(decoded.header).toEqual(["#", "x", "y"]); + expect(decoded.rows).toEqual([["0", "0", "1"], ["1", "2", "3"]]); + expect(decoded.truncated).toBe(false); + }); + + it("honors stride and offset when interleaved", () => { + // Two vertices, stride 16 bytes, the vec2 at offset 4. + const raw = new Float32Array([9, 0, 1, 9, 9, 2, 3, 9]); + const decoded = decodeVertexRows(decodeBase64Bytes(encode(new Uint8Array(raw.buffer))), + { kind: "vertex", componentType: "FLOAT", components: 2, stride: 16, offset: 4 }, 100); + expect(decoded.rows).toEqual([["0", "0", "1"], ["1", "2", "3"]]); + }); + + it("normalizes unsigned byte components when requested", () => { + const bytes = decodeBase64Bytes(encode(new Uint8Array([0, 255, 128, 255]))); + const decoded = decodeVertexRows(bytes, + { kind: "vertex", componentType: "UNSIGNED_BYTE", components: 2, stride: 0, offset: 0, normalized: true }, 100); + expect(decoded.rows[0]).toEqual(["0", "0", "1"]); + }); + + it("truncates to maxRows and flags it", () => { + const bytes = decodeBase64Bytes(encode(floats([0, 1, 2, 3, 4, 5]))); + const decoded = decodeVertexRows(bytes, vec2, 1); + expect(decoded.rows.length).toBe(1); + expect(decoded.truncated).toBe(true); + }); +}); + +describe("bufferView.decodeIndices", () => { + it("decodes unsigned short indices", () => { + const bytes = decodeBase64Bytes(encode(new Uint8Array(new Uint16Array([0, 1, 2, 2, 1, 3]).buffer))); + const decoded = decodeIndices(bytes, "UNSIGNED_SHORT", 100); + expect(decoded.header).toEqual(["#", "index"]); + expect(decoded.rows.map((r) => r[1])).toEqual(["0", "1", "2", "2", "1", "3"]); + }); +});