feat: add contextual layer inspector
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
import { createMaskedProgram, createMaskRevealPreviewProgram, createProgram, createTintedProgram, getMaskVisualizationResources, maskVisualizationModeValue, type MaskVisualizationResources } from "./image-texture-programs";
|
||||
import type { Asset } from "@core/asset";
|
||||
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
|
||||
import { rotatedRectBounds, rotatedRectCorners } from "./rotated-rect";
|
||||
|
||||
export type MaskVisualizationMode = "blackWhite" | "alpha" | "hiddenOverlay";
|
||||
|
||||
export type ImageTextureRenderer = {
|
||||
syncAssets(assets: readonly Asset[]): void;
|
||||
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number): boolean;
|
||||
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect, opacity?: number): boolean;
|
||||
renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number): boolean;
|
||||
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number): boolean;
|
||||
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number): boolean;
|
||||
renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number, rotation?: number): boolean;
|
||||
renderMaskVisualization(maskAsset: Asset, maskRect: ScreenRect, mode: MaskVisualizationMode, color?: RgbaColor, clipRect?: ScreenRect): boolean;
|
||||
renderTinted(asset: Asset, rect: ScreenRect, color: RgbaColor, clipRect?: ScreenRect, opacity?: number): boolean;
|
||||
dispose(): void;
|
||||
@@ -87,10 +88,11 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
}
|
||||
}
|
||||
},
|
||||
render(asset, rect, clipRect, opacity = 1) {
|
||||
render(asset, rect, clipRect, opacity = 1, rotation = 0) {
|
||||
const clampedOpacity = clampOpacity(opacity);
|
||||
if (clampedOpacity <= 0) return true;
|
||||
const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
|
||||
const rotatedRect = rotatedRectBounds(rect, rotation);
|
||||
const drawRect = clipRect ? intersectScreenRects(rotatedRect, clipRect) : rotatedRect;
|
||||
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
|
||||
|
||||
const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed);
|
||||
@@ -108,7 +110,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.uniform1f(opacityLocation, clampedOpacity);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect, rotation), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(positionLocation);
|
||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
@@ -121,11 +123,13 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.disable(gl.BLEND);
|
||||
return true;
|
||||
},
|
||||
renderMasked(asset, rect, maskAsset, maskRect, clipRect, opacity = 1) {
|
||||
renderMasked(asset, rect, maskAsset, maskRect, clipRect, opacity = 1, rotation = 0) {
|
||||
const clampedOpacity = clampOpacity(opacity);
|
||||
if (clampedOpacity <= 0) return true;
|
||||
const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
|
||||
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskRect) : undefined;
|
||||
const contentBounds = rotatedRectBounds(rect, rotation);
|
||||
const maskBounds = rotatedRectBounds(maskRect, rotation);
|
||||
const clippedRect = clipRect ? intersectScreenRects(contentBounds, clipRect) : contentBounds;
|
||||
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskBounds) : undefined;
|
||||
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
|
||||
|
||||
const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed);
|
||||
@@ -148,17 +152,17 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.uniform1f(maskedOpacityLocation, clampedOpacity);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, drawRect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect, rotation), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(maskedPositionLocation);
|
||||
gl.vertexAttribPointer(maskedPositionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, rect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(maskedTexCoordLocation);
|
||||
gl.vertexAttribPointer(maskedTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, maskTexCoordBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, maskRect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(maskedMaskTexCoordLocation);
|
||||
gl.vertexAttribPointer(maskedMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
@@ -166,12 +170,14 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.disable(gl.BLEND);
|
||||
return true;
|
||||
},
|
||||
renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1) {
|
||||
renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1, rotation = 0) {
|
||||
const clampedOpacity = clampOpacity(opacity) * clampOpacity(layerOpacity);
|
||||
if (clampedOpacity <= 0) return true;
|
||||
|
||||
const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
|
||||
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskRect) : undefined;
|
||||
const contentBounds = rotatedRectBounds(rect, rotation);
|
||||
const maskBounds = rotatedRectBounds(maskRect, rotation);
|
||||
const clippedRect = clipRect ? intersectScreenRects(contentBounds, clipRect) : contentBounds;
|
||||
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskBounds) : undefined;
|
||||
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
|
||||
|
||||
const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed);
|
||||
@@ -194,17 +200,17 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.uniform1f(maskRevealPreviewOpacityLocation, clampedOpacity);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, drawRect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect, rotation), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(maskRevealPreviewPositionLocation);
|
||||
gl.vertexAttribPointer(maskRevealPreviewPositionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, rect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(maskRevealPreviewTexCoordLocation);
|
||||
gl.vertexAttribPointer(maskRevealPreviewTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, maskTexCoordBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, maskRect), gl.DYNAMIC_DRAW);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
|
||||
gl.enableVertexAttribArray(maskRevealPreviewMaskTexCoordLocation);
|
||||
gl.vertexAttribPointer(maskRevealPreviewMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
@@ -422,11 +428,10 @@ function texCoordsForRect(drawRect: ScreenRect, sourceRect: ScreenRect) {
|
||||
return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]);
|
||||
}
|
||||
|
||||
function rectVertices(canvas: HTMLCanvasElement, rect: ScreenRect) {
|
||||
const x1 = (rect.x / canvas.width) * 2 - 1;
|
||||
const x2 = ((rect.x + rect.w) / canvas.width) * 2 - 1;
|
||||
const y1 = 1 - (rect.y / canvas.height) * 2;
|
||||
const y2 = 1 - ((rect.y + rect.h) / canvas.height) * 2;
|
||||
|
||||
return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]);
|
||||
function rectVertices(canvas: HTMLCanvasElement, rect: ScreenRect, rotation = 0) {
|
||||
const [nw, ne, sw, se] = rotatedRectCorners(rect, rotation).map((point) => ({
|
||||
x: (point.x / canvas.width) * 2 - 1,
|
||||
y: 1 - (point.y / canvas.height) * 2,
|
||||
}));
|
||||
return new Float32Array([nw!.x, nw!.y, ne!.x, ne!.y, sw!.x, sw!.y, sw!.x, sw!.y, ne!.x, ne!.y, se!.x, se!.y]);
|
||||
}
|
||||
|
||||
@@ -91,17 +91,17 @@ function renderLeafLayer(
|
||||
if (asset && maskAsset && maskRect && activeMaskTarget) {
|
||||
if (maskViewMode === "blackWhite" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "blackWhite", undefined, effectiveClipRect)) return;
|
||||
if (maskViewMode === "alpha" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "alpha", undefined, effectiveClipRect)) return;
|
||||
if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity)) {
|
||||
if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation)) {
|
||||
imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "hiddenOverlay", hiddenMaskOverlayColor, effectiveClipRect);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect, effectiveOpacity)) {
|
||||
if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect, effectiveOpacity);
|
||||
if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect, effectiveOpacity, layer.transform.rotation)) {
|
||||
if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect, effectiveOpacity, layer.transform.rotation);
|
||||
return;
|
||||
}
|
||||
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity)) return;
|
||||
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation)) return;
|
||||
|
||||
const fallbackRect = intersectScreenRects(rect, effectiveClipRect);
|
||||
if (!fallbackRect) return;
|
||||
|
||||
40
renderer/rotated-rect.test.ts
Normal file
40
renderer/rotated-rect.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { rotatedRectBounds, rotatedRectCorners } from "./rotated-rect";
|
||||
|
||||
describe("rotated rectangle geometry", () => {
|
||||
test("interprets rotation as radians and keeps the center anchored", () => {
|
||||
const corners = rotatedRectCorners({ x: 10, y: 20, w: 40, h: 20 }, Math.PI / 2);
|
||||
|
||||
expectPoint(corners[0], { x: 40, y: 10 });
|
||||
expectPoint(corners[1], { x: 40, y: 50 });
|
||||
expectPoint(corners[2], { x: 20, y: 10 });
|
||||
expectPoint(corners[3], { x: 20, y: 50 });
|
||||
expect((corners[0].x + corners[3].x) / 2).toBeCloseTo(30);
|
||||
expect((corners[0].y + corners[3].y) / 2).toBeCloseTo(30);
|
||||
});
|
||||
|
||||
test("expands axis-aligned clipping bounds around a rotated quad", () => {
|
||||
const bounds = rotatedRectBounds({ x: 10, y: 20, w: 40, h: 20 }, Math.PI / 2);
|
||||
|
||||
expect(bounds.x).toBeCloseTo(20);
|
||||
expect(bounds.y).toBeCloseTo(10);
|
||||
expect(bounds.w).toBeCloseTo(20);
|
||||
expect(bounds.h).toBeCloseTo(40);
|
||||
});
|
||||
|
||||
test("preserves an unrotated rectangle exactly", () => {
|
||||
const rect = { x: 10, y: 20, w: 40, h: 20 };
|
||||
expect(rotatedRectBounds(rect, 0)).toBe(rect);
|
||||
expect(rotatedRectCorners(rect, 0)).toEqual([
|
||||
{ x: 10, y: 20 },
|
||||
{ x: 50, y: 20 },
|
||||
{ x: 10, y: 40 },
|
||||
{ x: 50, y: 40 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function expectPoint(actual: { x: number; y: number }, expected: { x: number; y: number }) {
|
||||
expect(actual.x).toBeCloseTo(expected.x);
|
||||
expect(actual.y).toBeCloseTo(expected.y);
|
||||
}
|
||||
33
renderer/rotated-rect.ts
Normal file
33
renderer/rotated-rect.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ScreenRect } from "./types";
|
||||
|
||||
export type ScreenPoint = { x: number; y: number };
|
||||
|
||||
/** Returns corners in northwest, northeast, southwest, southeast order. */
|
||||
export function rotatedRectCorners(rect: ScreenRect, rotation: number): [ScreenPoint, ScreenPoint, ScreenPoint, ScreenPoint] {
|
||||
if (!Number.isFinite(rotation)) rotation = 0;
|
||||
const center = { x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 };
|
||||
const cosine = Math.cos(rotation);
|
||||
const sine = Math.sin(rotation);
|
||||
const rotate = (point: ScreenPoint): ScreenPoint => {
|
||||
const x = point.x - center.x;
|
||||
const y = point.y - center.y;
|
||||
return { x: center.x + x * cosine - y * sine, y: center.y + x * sine + y * cosine };
|
||||
};
|
||||
return [
|
||||
rotate({ x: rect.x, y: rect.y }),
|
||||
rotate({ x: rect.x + rect.w, y: rect.y }),
|
||||
rotate({ x: rect.x, y: rect.y + rect.h }),
|
||||
rotate({ x: rect.x + rect.w, y: rect.y + rect.h }),
|
||||
];
|
||||
}
|
||||
|
||||
/** Axis-aligned screen bounds used to constrain WebGL scissoring around a rotated quad. */
|
||||
export function rotatedRectBounds(rect: ScreenRect, rotation: number): ScreenRect {
|
||||
if (rotation === 0) return rect;
|
||||
const points = rotatedRectCorners(rect, rotation);
|
||||
const xs = points.map((point) => point.x);
|
||||
const ys = points.map((point) => point.y);
|
||||
const x = Math.min(...xs);
|
||||
const y = Math.min(...ys);
|
||||
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
|
||||
}
|
||||
Reference in New Issue
Block a user