import {
Application,
Container,
Graphics,
Mesh,
MeshGeometry,
Shader,
UniformGroup,
} from "pixi.js";
const vertex = /* language=GLSL */ `
#version 300 es
in vec2 aPosition;
in vec2 aUV;
out vec2 vPixelPos;
uniform mat3 uProjectionMatrix;
uniform mat3 uTransformMatrix;
void main() {
vPixelPos = aUV;
vec3 pos = uProjectionMatrix * uTransformMatrix * vec3(aPosition, 1.0);
gl_Position = vec4(pos.xy, 0.0, 1.0);
}
`;
const fragment = /* language=GLSL */ `
#version 300 es
in vec2 vPixelPos;
out vec4 fragColor;
uniform float uTime;
uniform float uCenterX;
uniform float uCenterY;
uniform float uOmega;
uniform float uKr;
uniform float uAmp;
void main() {
float dx = vPixelPos.x - uCenterX;
float dy = vPixelPos.y - uCenterY;
float r = sqrt(dx * dx + dy * dy);
// concentric animated rings
float v = uAmp + uAmp * sin(r * uKr - uTime * uOmega);
fragColor = vec4(v, v, v, 1.0);
}
`;
const softCircleFragment = /* language=GLSL */ `
#version 300 es
in vec2 vPixelPos;
out vec4 fragColor;
uniform float uCenterX;
uniform float uCenterY;
uniform float uDiameter;
uniform float uEdgeWidth;
void main() {
const float PI = 3.14159265;
float r = length(vPixelPos - vec2(uCenterX, uCenterY));
float t = clamp((r - (uDiameter * 0.5 - uEdgeWidth * 0.5)) / uEdgeWidth, 0.0, 1.0);
float v = 0.5 * (1.0 + cos(PI * t));
fragColor = vec4(v, v, v, 1.0);
}
`;
type SoftCircleUniforms = {
uCenterX: { value: number; type: "f32" };
uCenterY: { value: number; type: "f32" };
uDiameter: { value: number; type: "f32" };
uEdgeWidth: { value: number; type: "f32" };
};
type RippleUniforms = {
uTime: { value: number; type: "f32" };
uCenterX: { value: number; type: "f32" };
uCenterY: { value: number; type: "f32" };
uOmega: { value: number; type: "f32" };
uKr: { value: number; type: "f32" };
uAmp: { value: number; type: "f32" };
};
const SQRT2_INV = 1 / Math.sqrt(2);
function quadVerts(w: number, h: number) {
return new Float32Array([0, 0, w, 0, w, h, 0, h]);
}
function makeShaderWidget(
initialW: number,
initialH: number,
): {
container: Container;
uniforms: UniformGroup<RippleUniforms>;
resize: (w: number, h: number) => void;
destroy: () => void;
} {
const geometry = new MeshGeometry({
positions: quadVerts(initialW, initialH),
uvs: quadVerts(initialW, initialH),
indices: new Uint32Array([0, 1, 2, 0, 2, 3]),
});
const uniforms = new UniformGroup<RippleUniforms>({
uTime: { value: 0.0, type: "f32" },
uCenterX: { value: initialW / 2, type: "f32" },
uCenterY: { value: initialH / 2, type: "f32" },
uOmega: { value: 2.4, type: "f32" },
uKr: { value: 0.05, type: "f32" },
uAmp: { value: 0.5, type: "f32" },
});
const shader = Shader.from({
gl: { vertex, fragment },
resources: { shaderUniforms: uniforms },
});
const container = new Container();
container.addChild(new Mesh({ geometry, shader }));
function resize(w: number, h: number) {
geometry.positions = quadVerts(w, h);
geometry.uvs = quadVerts(w, h);
uniforms.uniforms.uCenterX = w / 2;
uniforms.uniforms.uCenterY = h / 2;
}
function destroy() {
geometry.destroy();
shader.destroy();
container.destroy({ children: true });
}
return { container, uniforms, resize, destroy };
}
function makeSoftCircleWidget(
initialW: number,
initialH: number,
): {
container: Container;
uniforms: UniformGroup<SoftCircleUniforms>;
resize: (w: number, h: number) => void;
destroy: () => void;
} {
const geometry = new MeshGeometry({
positions: quadVerts(initialW, initialH),
uvs: quadVerts(initialW, initialH),
indices: new Uint32Array([0, 1, 2, 0, 2, 3]),
});
const uniforms = new UniformGroup<SoftCircleUniforms>({
uCenterX: { value: initialW / 2, type: "f32" },
uCenterY: { value: initialH / 2, type: "f32" },
uDiameter: { value: Math.min(initialW, initialH) * 0.7, type: "f32" },
uEdgeWidth: { value: 30.0, type: "f32" },
});
const shader = Shader.from({
gl: { vertex, fragment: softCircleFragment },
resources: { shaderUniforms: uniforms },
});
const container = new Container();
container.addChild(new Mesh({ geometry, shader }));
function resize(w: number, h: number) {
geometry.positions = quadVerts(w, h);
geometry.uvs = quadVerts(w, h);
uniforms.uniforms.uCenterX = w / 2;
uniforms.uniforms.uCenterY = h / 2;
}
function destroy() {
geometry.destroy();
shader.destroy();
container.destroy({ children: true });
}
return { container, uniforms, resize, destroy };
}
function makeSoftCircleControls(
stage: Container,
widgetContainer: Container,
uniforms: UniformGroup<SoftCircleUniforms>,
): { container: Container; redraw: () => void; destroy: () => void } {
const handleLayer = new Graphics();
const centerHandle = new Graphics();
centerHandle.eventMode = "static";
centerHandle.cursor = "move";
const radiusHandle = new Graphics();
radiusHandle.eventMode = "static";
radiusHandle.cursor = "ew-resize";
const edgeInnerHandle = new Graphics();
edgeInnerHandle.eventMode = "static";
edgeInnerHandle.cursor = "nesw-resize";
const edgeOuterHandle = new Graphics();
edgeOuterHandle.eventMode = "static";
edgeOuterHandle.cursor = "nesw-resize";
const container = new Container();
container.addChild(
handleLayer,
centerHandle,
radiusHandle,
edgeInnerHandle,
edgeOuterHandle,
);
function redraw() {
const cx = uniforms.uniforms.uCenterX;
const cy = uniforms.uniforms.uCenterY;
const r = uniforms.uniforms.uDiameter / 2;
const hw = uniforms.uniforms.uEdgeWidth / 2;
const rInner = r - hw;
const rOuter = r + hw;
const guideStroke = { color: 0x44aaff, width: 1 };
handleLayer
.clear()
.moveTo(cx, cy)
.lineTo(cx + r, cy)
.stroke(guideStroke)
.moveTo(cx + rInner * SQRT2_INV, cy - rInner * SQRT2_INV)
.lineTo(cx + rOuter * SQRT2_INV, cy - rOuter * SQRT2_INV)
.stroke(guideStroke);
const outline = { color: 0x000000, width: 1.5 };
centerHandle
.clear()
.circle(cx, cy, 5)
.fill({ color: 0xffdd00 })
.stroke(outline);
radiusHandle
.clear()
.circle(cx + r, cy, 5)
.fill({ color: 0x44ff88 })
.stroke(outline);
edgeInnerHandle
.clear()
.circle(cx + rInner * SQRT2_INV, cy - rInner * SQRT2_INV, 5)
.fill({ color: 0xff8844 })
.stroke(outline);
edgeOuterHandle
.clear()
.circle(cx + rOuter * SQRT2_INV, cy - rOuter * SQRT2_INV, 5)
.fill({ color: 0xff8844 })
.stroke(outline);
}
redraw();
function localPos(e: { global: { x: number; y: number } }) {
return widgetContainer.toLocal(e.global);
}
function projectedR(e: { global: { x: number; y: number } }) {
const p = localPos(e);
const px = p.x - uniforms.uniforms.uCenterX;
const py = p.y - uniforms.uniforms.uCenterY;
return (px - py) * SQRT2_INV;
}
// --- Center drag ---
const centerOffset = { x: 0, y: 0 };
function onCenterMove(e: { global: { x: number; y: number } }) {
const p = localPos(e);
uniforms.uniforms.uCenterX = p.x - centerOffset.x;
uniforms.uniforms.uCenterY = p.y - centerOffset.y;
redraw();
}
function onCenterEnd() {
stage.off("pointermove", onCenterMove);
window.removeEventListener("pointerup", onCenterEnd);
}
centerHandle.on("pointerdown", (e) => {
onCenterEnd();
const p = localPos(e);
centerOffset.x = p.x - uniforms.uniforms.uCenterX;
centerOffset.y = p.y - uniforms.uniforms.uCenterY;
stage.on("pointermove", onCenterMove);
window.addEventListener("pointerup", onCenterEnd);
});
// --- Radius drag ---
let radiusOffsetX = 0;
function onRadiusMove(e: { global: { x: number; y: number } }) {
const p = localPos(e);
const newR = Math.max(
uniforms.uniforms.uEdgeWidth / 2 + 1,
p.x - uniforms.uniforms.uCenterX - radiusOffsetX,
);
uniforms.uniforms.uDiameter = newR * 2;
redraw();
}
function onRadiusEnd() {
stage.off("pointermove", onRadiusMove);
window.removeEventListener("pointerup", onRadiusEnd);
}
radiusHandle.on("pointerdown", (e) => {
onRadiusEnd();
const p = localPos(e);
radiusOffsetX =
p.x - uniforms.uniforms.uCenterX - uniforms.uniforms.uDiameter / 2;
stage.on("pointermove", onRadiusMove);
window.addEventListener("pointerup", onRadiusEnd);
});
// --- Edge width drag ---
let edgeDragOffset = 0;
function onInnerEdgeMove(e: { global: { x: number; y: number } }) {
const newRInner = projectedR(e) - edgeDragOffset;
uniforms.uniforms.uEdgeWidth = Math.max(
1,
2 * (uniforms.uniforms.uDiameter / 2 - newRInner),
);
redraw();
}
function onOuterEdgeMove(e: { global: { x: number; y: number } }) {
const newROuter = projectedR(e) - edgeDragOffset;
uniforms.uniforms.uEdgeWidth = Math.max(
1,
2 * (newROuter - uniforms.uniforms.uDiameter / 2),
);
redraw();
}
function onInnerEdgeEnd() {
stage.off("pointermove", onInnerEdgeMove);
window.removeEventListener("pointerup", onInnerEdgeEnd);
}
function onOuterEdgeEnd() {
stage.off("pointermove", onOuterEdgeMove);
window.removeEventListener("pointerup", onOuterEdgeEnd);
}
edgeInnerHandle.on("pointerdown", (e) => {
onInnerEdgeEnd();
edgeDragOffset =
projectedR(e) -
(uniforms.uniforms.uDiameter / 2 - uniforms.uniforms.uEdgeWidth / 2);
stage.on("pointermove", onInnerEdgeMove);
window.addEventListener("pointerup", onInnerEdgeEnd);
});
edgeOuterHandle.on("pointerdown", (e) => {
onOuterEdgeEnd();
edgeDragOffset =
projectedR(e) -
(uniforms.uniforms.uDiameter / 2 + uniforms.uniforms.uEdgeWidth / 2);
stage.on("pointermove", onOuterEdgeMove);
window.addEventListener("pointerup", onOuterEdgeEnd);
});
function destroy() {
onCenterEnd();
onRadiusEnd();
onInnerEdgeEnd();
onOuterEdgeEnd();
container.destroy({ children: true });
}
return { container, redraw, destroy };
}
function makeResizableChrome(
stage: Container,
inner: Container,
initialW: number,
initialH: number,
onResize: (w: number, h: number) => void,
): { container: Container; destroy: () => void } {
let W = initialW;
let H = initialH;
const frame = new Graphics();
const moveHandle = new Graphics();
moveHandle.eventMode = "static";
moveHandle.cursor = "grab";
const resizeHandle = new Graphics();
resizeHandle.eventMode = "static";
resizeHandle.cursor = "nwse-resize";
function redraw() {
frame
.clear()
.rect(-1, -1, W + 2, H + 2)
.stroke({ color: 0xffffff, width: 1 });
moveHandle.clear().circle(-1, -1, 5).fill({ color: 0x4488ff });
resizeHandle
.clear()
.circle(W + 1, H + 1, 5)
.fill({ color: 0xffffff });
}
redraw();
const container = new Container();
container.addChild(inner, frame, moveHandle, resizeHandle);
const dragOffset = { x: 0, y: 0 };
function onResize_(e: { global: { x: number; y: number } }) {
W = Math.max(50, e.global.x - container.x - dragOffset.x);
H = Math.max(50, e.global.y - container.y - dragOffset.y);
onResize(W, H);
redraw();
}
function onMove(e: { global: { x: number; y: number } }) {
container.x = e.global.x - dragOffset.x;
container.y = e.global.y - dragOffset.y;
}
function onMoveEnd() {
moveHandle.cursor = "grab";
stage.off("pointermove", onMove);
window.removeEventListener("pointerup", onMoveEnd);
}
function onResizeEnd() {
stage.off("pointermove", onResize_);
window.removeEventListener("pointerup", onResizeEnd);
}
moveHandle.on("pointerdown", (e) => {
moveHandle.cursor = "grabbing";
onMoveEnd();
dragOffset.x = e.global.x - container.x;
dragOffset.y = e.global.y - container.y;
stage.on("pointermove", onMove);
window.addEventListener("pointerup", onMoveEnd);
});
resizeHandle.on("pointerdown", (e) => {
e.stopPropagation();
onResizeEnd();
dragOffset.x = e.global.x - container.x - W;
dragOffset.y = e.global.y - container.y - H;
stage.on("pointermove", onResize_);
window.addEventListener("pointerup", onResizeEnd);
});
function destroy() {
onMoveEnd();
onResizeEnd();
container.removeChild(inner);
container.destroy({ children: true });
}
return { container, destroy };
}
export async function main() {
const app = new Application();
await app.init({ background: "#111111", resizeTo: window, antialias: true });
document.getElementById("pixi-container")!.appendChild(app.canvas);
app.stage.eventMode = "static";
app.stage.hitArea = app.screen;
app.renderer.on("resize", () => {
app.stage.hitArea = app.screen;
});
const {
container: rippleDisplay,
uniforms: rippleUniforms,
resize: rippleResize,
} = makeShaderWidget(400, 300);
const { container: rippleChrome } = makeResizableChrome(
app.stage,
rippleDisplay,
400,
300,
rippleResize,
);
rippleChrome.x = 50;
rippleChrome.y = 100;
app.stage.addChild(rippleChrome);
const {
container: circleDisplay,
uniforms: circleUniforms,
resize: circleResize,
} = makeSoftCircleWidget(300, 300);
const circleControls = makeSoftCircleControls(
app.stage,
circleDisplay,
circleUniforms,
);
circleDisplay.addChild(circleControls.container);
const { container: circleChrome } = makeResizableChrome(
app.stage,
circleDisplay,
300,
300,
(w, h) => {
circleResize(w, h);
circleControls.redraw();
},
);
circleChrome.x = 500;
circleChrome.y = 100;
app.stage.addChild(circleChrome);
app.ticker.add((ticker) => {
rippleUniforms.uniforms.uTime += ticker.deltaMS / 1000;
});
const wrap = document.createElement("div");
wrap.style.cssText =
"position:fixed;top:16px;right:16px;display:flex;flex-direction:column;gap:8px;";
const linkStyle =
"padding:4px 12px;color:#88aacc;font-family:monospace;font-size:13px;text-decoration:none;display:block;";
const homeLink = document.createElement("a");
homeLink.href = "/";
homeLink.textContent = "← All demos";
homeLink.style.cssText = linkStyle;
wrap.appendChild(homeLink);
const codeLink = document.createElement("a");
codeLink.href = "scratchFragmentShaders_source.html";
codeLink.textContent = "View source";
codeLink.style.cssText = linkStyle;
wrap.appendChild(codeLink);
document.body.appendChild(wrap);
}