Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/editor/generators/bandsGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ function drawBands(
ctx.save();
ctx.fillStyle = color;
ctx.globalAlpha = params.stripeGlow;
ctx.filter = `blur(${Math.max(1, thickness * 0.35)}px)`;
// ctx.filter blur is in physical pixels and ignores ctx transforms,
// so multiply by the current x-scale to keep the glow consistent in
// logical units when the surface is rendered at a higher pixel scale.
const physicalScale = ctx.getTransform().a;
ctx.filter = `blur(${Math.max(1, thickness * 0.35) * physicalScale}px)`;
ctx.beginPath();
ctx.moveTo(cursor, -span / 2);
ctx.lineTo(cursor + thickness, -span / 2);
Expand Down
14 changes: 11 additions & 3 deletions src/editor/generators/noiseGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,17 @@ export const noiseGeneratorSpec: GeneratorSpec<NoiseGeneratorParams> = {
noiseCanvas = document.createElement('canvas');
noiseCtx = noiseCanvas.getContext('2d');
}
if (noiseCanvas.width !== w) noiseCanvas.width = w;
if (noiseCanvas.height !== h) noiseCanvas.height = h;
// Generate noise at the destination's physical pixel size so each output
// pixel has its own random sample. Without this, a logical-size noise
// canvas drawn into a scaled context produces blocky upscaled noise.
const t = ctx.getTransform();
const physW = Math.max(1, Math.round(w * t.a));
const physH = Math.max(1, Math.round(h * t.d));
if (noiseCanvas.width !== physW) noiseCanvas.width = physW;
if (noiseCanvas.height !== physH) noiseCanvas.height = physH;
if (!noiseCtx) return;

const image = noiseCtx.createImageData(w, h);
const image = noiseCtx.createImageData(physW, physH);
const data = image.data;
const alpha = Math.round(255 * params.intensity);
for (let i = 0; i < data.length; i += 4) {
Expand All @@ -45,6 +51,8 @@ export const noiseGeneratorSpec: GeneratorSpec<NoiseGeneratorParams> = {
noiseCtx.putImageData(image, 0, 0);
ctx.save();
ctx.globalCompositeOperation = 'overlay';
// Bypass the caller's scale transform so noise lands 1:1 in physical pixels.
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.drawImage(noiseCanvas, 0, 0);
ctx.restore();
},
Expand Down
16 changes: 12 additions & 4 deletions src/editor/generators/useGeneratorCanvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,23 @@ export function useGeneratorCanvas(
item: GeneratorCanvasItem,
canvasWidth: number,
canvasHeight: number,
pixelScale: number = 1,
): HTMLCanvasElement | null {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const prevParamsRef = useRef<GeneratorCanvasItem['generatorParams'] | null>(null);
const prevWidthRef = useRef(0);
const prevHeightRef = useRef(0);
const prevSeedRef = useRef(0);
const prevPixelScaleRef = useRef(0);
const [, forceRender] = useReducer((x: number) => x + 1, 0);

useEffect(() => {
if (
prevParamsRef.current === item.generatorParams &&
prevWidthRef.current === canvasWidth &&
prevHeightRef.current === canvasHeight &&
prevSeedRef.current === item.seed
prevSeedRef.current === item.seed &&
prevPixelScaleRef.current === pixelScale
) {
return;
}
Expand All @@ -30,6 +33,7 @@ export function useGeneratorCanvas(
prevWidthRef.current = canvasWidth;
prevHeightRef.current = canvasHeight;
prevSeedRef.current = item.seed;
prevPixelScaleRef.current = pixelScale;

const spec = getGenerator(item.generatorParams.generatorType);
if (!spec) return;
Expand All @@ -39,17 +43,21 @@ export function useGeneratorCanvas(
}

const canvas = canvasRef.current;
canvas.width = canvasWidth;
canvas.height = canvasHeight;
// Render at higher physical resolution so the bitmap stays sharp when
// the editor zooms past 100%. Generators draw in logical units (canvasW
// × canvasH); ctx.scale lifts that into physical pixels.
canvas.width = Math.max(1, Math.round(canvasWidth * pixelScale));
canvas.height = Math.max(1, Math.round(canvasHeight * pixelScale));

const ctx = canvas.getContext('2d');
if (!ctx) return;

ctx.setTransform(pixelScale, 0, 0, pixelScale, 0, 0);
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
spec.draw(ctx, canvasWidth, canvasHeight, item.generatorParams, item.seed);

forceRender();
}, [item.generatorParams, item.seed, canvasWidth, canvasHeight]);
}, [item.generatorParams, item.seed, canvasWidth, canvasHeight, pixelScale]);

return canvasRef.current;
}
91 changes: 76 additions & 15 deletions src/editor/rendering/stage/PixiItemLayer.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BlurFilter, ColorMatrixFilter, FillGradient, Graphics, Polygon, Rectangle, Texture } from 'pixi.js';
import { ColorMatrixFilter, FillGradient, Graphics, Polygon, Rectangle, Texture } from 'pixi.js';
import type { FederatedPointerEvent, Filter } from 'pixi.js';
import { DropShadowFilter } from 'pixi-filters';

import { ClampingBlurFilter } from './clampingBlurFilter';

import type {
CanvasItem,
CanvasTool,
Expand Down Expand Up @@ -301,7 +303,7 @@ function buildImageAdjustmentFilters(
// Image content component (uses hooks for async image loading + masking)
// ---------------------------------------------------------------------------

export function PixiImageContent({ item }: { item: ImageCanvasItem }) {
export function PixiImageContent({ item, zoom = 1 }: { item: ImageCanvasItem; zoom?: number }) {
const imageElement = useImageElement(item.src);
const [maskNode, setMaskNode] = useState<Graphics | null>(null);

Expand All @@ -321,10 +323,17 @@ export function PixiImageContent({ item }: { item: ImageCanvasItem }) {
[imageElement],
);

const adjustmentFilters = useMemo(
() => buildImageAdjustmentFilters(item.adjustments),
[item.adjustments],
);
const adjustmentFilters = useMemo(() => {
const filters = buildImageAdjustmentFilters(item.adjustments);
if (!filters) return undefined;
// Match filter resolution to displayed size so color-corrected pixels
// stay sharp on zoom-in. Cap at 2× so the FBO doesn't exceed the GPU's
// max texture size on large items at extreme zoom (clipping shows up as
// rectangular edges on filtered output).
const filterResolution = Math.min(2, Math.max(1, zoom)) * (window.devicePixelRatio || 1);
for (const f of filters) f.resolution = filterResolution;
return filters;
}, [item.adjustments, zoom]);

const drawClipMask = useCallback(
(g: Graphics) => {
Expand Down Expand Up @@ -368,16 +377,28 @@ export function PixiImageContent({ item }: { item: ImageCanvasItem }) {
// Generator content component (renders full-canvas pattern via Canvas2D texture)
// ---------------------------------------------------------------------------

// Quantize the zoom-driven pixel scale to powers of two so we don't
// re-rasterize the generator on every zoom tick. Caps at 4× to bound
// memory (a 2048² canvas at 4× is 8192² — ~256 MB at RGBA8).
function quantizeGeneratorPixelScale(zoom: number): number {
if (zoom <= 1) return 1;
if (zoom <= 2) return 2;
return 4;
}

function PixiGeneratorContent({
item,
canvasWidth,
canvasHeight,
zoom,
}: {
item: GeneratorCanvasItem;
canvasWidth: number;
canvasHeight: number;
zoom: number;
}) {
const generatorCanvas = useGeneratorCanvas(item, canvasWidth, canvasHeight);
const pixelScale = quantizeGeneratorPixelScale(zoom);
const generatorCanvas = useGeneratorCanvas(item, canvasWidth, canvasHeight, pixelScale);

const texture = useMemo(
() => (generatorCanvas ? Texture.from(generatorCanvas) : Texture.EMPTY),
Expand All @@ -389,7 +410,7 @@ function PixiGeneratorContent({
if (texture !== Texture.EMPTY) {
texture.source.update();
}
}, [texture, item.generatorParams, item.seed, canvasWidth, canvasHeight]);
}, [texture, item.generatorParams, item.seed, canvasWidth, canvasHeight, pixelScale]);

if (!generatorCanvas) return null;

Expand Down Expand Up @@ -564,19 +585,47 @@ const PixiItemView = memo(function PixiItemView({
const itemFilters = useMemo(() => {
const filters: Filter[] = [];
if (item.blurRadius > 0) {
filters.push(new BlurFilter({ strength: item.blurRadius * zoom }));
const strength = item.blurRadius * zoom;
// ClampingBlurFilter is a Gaussian BlurFilter with shaders that clamp
// sample UVs to the input texture's active frame (uInputClamp).
// Pixi's stock BlurFilter samples raw UVs; TexturePool reuses power-
// of-two textures whose pixels between the frame and po2 boundary
// keep stale data from previous use, and the blur kernel pulls those
// bright values into the result. Clamping eliminates that read.
const blurFilter = new ClampingBlurFilter({
strength,
clipToViewport: false,
});
// With clamping in place, padding determines where the visible blur
// halo ends. Pixi's auto-padding (strength*2) cuts the Gaussian tail
// off mid-falloff; the optimized 4-pass kernel reaches ~5× strength,
// so pad to 6× for a smooth fadeout.
blurFilter.padding = strength * 6;
filters.push(blurFilter);
}
if (item.kind !== 'text') {
const s = item.shadow;
const hasShadow = s && (s.blur > 0 || s.offsetX !== 0 || s.offsetY !== 0) && s.opacity > 0;
if (hasShadow) {
filters.push(new DropShadowFilter({
const shadowBlur = (s.blur / 2) * zoom;
const shadowOffsetX = s.offsetX * zoom;
const shadowOffsetY = s.offsetY * zoom;
const shadowFilter = new DropShadowFilter({
color: s.color,
alpha: s.opacity,
blur: (s.blur / 2) * zoom,
offset: { x: s.offsetX * zoom, y: s.offsetY * zoom },
blur: shadowBlur,
offset: { x: shadowOffsetX, y: shadowOffsetY },
quality: 8,
}));
});
shadowFilter.clipToViewport = false;
// DropShadowFilter wraps a Kawase blur whose reach scales with
// blur × quality. Auto-padding (offset + blur*2 + quality*4) clips
// the tail; extend it the same way as BlurFilter.
shadowFilter.padding =
Math.max(Math.abs(shadowOffsetX), Math.abs(shadowOffsetY)) +
shadowBlur * 6 +
32;
filters.push(shadowFilter);
}
}
return filters.length > 0 ? filters : undefined;
Expand Down Expand Up @@ -640,6 +689,7 @@ const PixiItemView = memo(function PixiItemView({
item={item as GeneratorCanvasItem}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
zoom={zoom}
/>
</pixiContainer>
);
Expand All @@ -649,11 +699,22 @@ const PixiItemView = memo(function PixiItemView({
let children: React.ReactNode;
if (item.kind === 'text') {
const { style: textStyle, textX, textY } = buildTextStyleProps(item);
// Pixi rasterizes Text to a texture at `resolution * devicePixelRatio`.
// When zoom > 1 the texture is magnified and turns blurry, so scale
// resolution with zoom to keep glyphs sharp on zoom-in.
const textResolution = Math.max(1, zoom) * (window.devicePixelRatio || 1);
children = (
<pixiText text={item.text} style={textStyle} x={textX} y={textY} eventMode="none" />
<pixiText
text={item.text}
style={textStyle}
x={textX}
y={textY}
resolution={textResolution}
eventMode="none"
/>
);
} else if (item.kind === 'image') {
children = <PixiImageContent item={item as ImageCanvasItem} />;
children = <PixiImageContent item={item as ImageCanvasItem} zoom={zoom} />;
} else {
children = <pixiGraphics draw={draw} eventMode="none" />;
}
Expand Down
Loading
Loading