uiception
blockportfolio-v5source: ok · refreshed
Portfolio v5
Left-column about and projects over a WebGPU Optimized Black Hole background — Jon Doe filler. New projects only (shadcn init --template next).
npx shadcn add "https://uiception.com/r/portfolio-v5.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightvgpu@vgpu/wgsl@webgpu/typesclass-variance-authorityradix-ui
Files22 · source cached 10/8/2026, 8:38:45 AM
registry/new-york/templates/portfolio-v5/components/ui/button.tsx
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
registry/new-york/templates/portfolio-v5/app/layout.tsx
import type { Metadata, Viewport } from "next"
import { Oxanium } from "next/font/google"
import type { ReactNode } from "react"
import { BlackHole } from "../components/black-hole"
import "./globals.css"
const oxanium = Oxanium({
subsets: ["latin"],
variable: "--font-oxanium",
})
export const metadata: Metadata = {
title: "Jon Doe",
description:
"Fullstack developer — about and projects over a WebGPU black hole.",
}
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
colorScheme: "dark",
themeColor: "#000000",
}
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html
lang="en"
className={`${oxanium.variable} dark h-full antialiased`}
suppressHydrationWarning
>
<body className="relative m-0 min-h-svh overflow-x-hidden bg-black p-0 font-sans text-base text-white antialiased">
<div
className="fixed inset-0 z-0 overflow-hidden bg-black"
aria-hidden
>
<BlackHole />
</div>
{children}
</body>
</html>
)
}
registry/new-york/templates/portfolio-v5/app/page.tsx
import { Button } from "@/components/ui/button"
const projects = [
{
name: "Panfry",
href: "https://github.com/",
description: "A small recipe box for the meals I actually cook on repeat.",
},
{
name: "Longmile",
href: "https://github.com/",
description:
"Tracks my slow, patient return to running after a bad knee.",
},
{
name: "Spinebox",
href: "https://github.com/",
description: "A shelf for the books I have actually finished this year.",
},
{
name: "Coinwell",
href: "https://github.com/",
description: "A simple, honest look at where my money actually goes.",
},
] as const
export default function HomePage() {
return (
<main className="pointer-events-none relative z-10 max-w-md px-4 pt-[max(16vh,calc(1.5rem+env(safe-area-inset-top)))] pb-16 text-base text-white sm:max-w-lg sm:px-6 md:max-w-md md:px-8 lg:max-w-lg">
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-5">
<nav className="pointer-events-auto flex flex-wrap gap-x-4 gap-y-1">
<Button
variant="link"
asChild
className="h-auto p-0 text-base font-normal text-white underline decoration-white/40 underline-offset-4 hover:decoration-white"
>
<a
href="https://instagram.com"
target="_blank"
rel="noreferrer"
>
Instagram
</a>
</Button>
<Button
variant="link"
asChild
className="h-auto p-0 text-base font-normal text-white underline decoration-white/40 underline-offset-4 hover:decoration-white"
>
<a href="https://x.com" target="_blank" rel="noreferrer">
X
</a>
</Button>
<Button
variant="link"
asChild
className="h-auto p-0 text-base font-normal text-white underline decoration-white/40 underline-offset-4 hover:decoration-white"
>
<a href="https://github.com" target="_blank" rel="noreferrer">
GitHub
</a>
</Button>
</nav>
<section className="pointer-events-auto flex flex-col gap-3 leading-relaxed">
<p>
I'm Jon, a fullstack developer in Portland. Most of my work
is with early AI infrastructure teams.
</p>
<p>
I keep the stack small and ship in small pieces. I want to leave
code the next person can read without me there to explain it.
</p>
<p>
Outside work I cook the same handful of dinners on repeat.
I'm easing back into running after a bad knee, and working
through a pile of books I bought and never opened.
</p>
</section>
</div>
<section className="pointer-events-auto flex flex-col gap-4">
<h2 className="text-base font-normal">Projects</h2>
<ul className="flex flex-col gap-4">
{projects.map((project) => (
<li key={project.name} className="flex flex-col gap-1">
<Button
variant="link"
asChild
className="h-auto w-fit justify-start p-0 text-base font-normal text-white underline decoration-white/40 underline-offset-4 hover:decoration-white"
>
<a href={project.href} target="_blank" rel="noreferrer">
{project.name}
</a>
</Button>
<span>{project.description}</span>
</li>
))}
</ul>
</section>
</div>
</main>
)
}
registry/new-york/templates/portfolio-v5/app/globals.css
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0 0 0);
--muted-foreground: oklch(0.708 0 0);
--border: oklch(1 0 0 / 15%);
--ring: oklch(0.985 0 0 / 40%);
}
@theme inline {
--font-sans: var(--font-oxanium);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
--color-ring: var(--ring);
}
@layer base {
* {
@apply border-border;
}
html,
body {
@apply bg-black text-base text-white;
}
}
registry/new-york/templates/portfolio-v5/components/black-hole.tsx
"use client"
import { useEffect, useRef } from "react"
import { createRenderer } from "./black-hole/renderer"
/**
* Optimized Black Hole (vgpu). Mount inside a sized ancestor (layout uses
* `fixed inset-0`). Skips WebGPU when the user prefers reduced motion or
* when init fails — the page stays black either way.
*/
export function BlackHole() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches
if (reduceMotion) return
const renderer = createRenderer({ canvas })
void renderer.ready.catch((error: unknown) => {
console.error("[black-hole] renderer failed", error)
})
return () => {
renderer.dispose()
}
}, [])
return (
<canvas ref={canvasRef} className="block h-full w-full touch-none" />
)
}
registry/new-york/templates/portfolio-v5/components/black-hole/renderer.ts
// Browser lifecycle for the baked black-hole pipeline. VGPU stays dynamically imported.
import type { Frame, Gpu, Surface } from "vgpu";
type VgpuApi = typeof import("vgpu");
import {
createEffects,
createTargets,
destroyTargets,
prewarm,
renderChain,
setBakeUniforms,
setBindings,
setPostUniforms,
setShadeUniforms,
type Effects,
type Targets,
} from "./pipeline";
import { defaultHeroSettings } from "./settings";
const SCENE_YAW_TAU_S = 0.325;
const MAX_FRAME_DT_S = 0.1;
const TARGET_FPS = 60;
const FRAME_PACING_EPSILON_MS = 2;
const MIN_FRAME_INTERVAL_MS = 1000 / TARGET_FPS - FRAME_PACING_EPSILON_MS;
const MOBILE_QUERY = "(max-width: 767px)";
interface RendererOptions {
canvas: HTMLCanvasElement;
}
type RenderSize = { width: number; height: number };
export function createRenderer({ canvas }: RendererOptions) {
const settings = defaultHeroSettings();
const desktopLayout = {
centerX: settings.centerX,
centerY: settings.centerY,
cameraRoll: settings.cameraRoll,
mouseYaw: settings.mouseYaw,
centerFade: settings.centerFade,
};
const mobileQuery = window.matchMedia(MOBILE_QUERY);
const applyResponsiveLayout = () => {
Object.assign(
settings,
mobileQuery.matches
? {
centerX: 0,
centerY: 0,
cameraRoll: 0,
mouseYaw: 0,
centerFade: 1,
}
: desktopLayout
);
};
applyResponsiveLayout();
const bloomScale = Math.min(Math.max(window.devicePixelRatio, 1), 2) / 2;
settings.bloom.radius *= bloomScale;
settings.bloom.strength *= bloomScale;
let disposed = false;
let api: VgpuApi | undefined;
let gpu: Gpu | undefined;
let surface: Surface | undefined;
let effects: Effects | undefined;
let targets: Targets | undefined;
let loop: { stop(): void } | undefined;
let observer: ResizeObserver | undefined;
let intersection: IntersectionObserver | undefined;
let documentVisible =
typeof document === "undefined" ? true : !document.hidden;
let canvasIntersecting = true;
let started = false;
let animationTime = 0;
let lastFrameAt: number | undefined;
let resizeFrame = 0;
let pendingSize: RenderSize | undefined;
let forceBake = true;
let pointerXNormalized = 0;
let currentSceneYaw = 0;
let lastYawAt: number | undefined;
const onLayoutChange = () => {
applyResponsiveLayout();
forceBake = true;
};
mobileQuery.addEventListener("change", onLayoutChange);
const onPointerMove = (event: PointerEvent) => {
if (event.pointerType !== "mouse") return;
const width = Math.max(window.innerWidth, 1);
pointerXNormalized = Math.min(
1,
Math.max(-1, (event.clientX / width) * 2 - 1)
);
};
const recenterPointer = () => {
pointerXNormalized = 0;
};
const onPointerOut = (event: PointerEvent) => {
if (event.relatedTarget === null) recenterPointer();
};
const onVisibilityChange = () => {
if (document.hidden) recenterPointer();
documentVisible = !document.hidden;
reconcileLoop();
};
function reconcileLoop(): void {
if (!started || !gpu || !api) return;
const shouldRun = !disposed && documentVisible && canvasIntersecting;
if (shouldRun === Boolean(loop)) return;
if (shouldRun) {
lastFrameAt = undefined;
lastYawAt = undefined;
loop = startPacedLoop(api, gpu);
} else {
loop?.stop();
loop = undefined;
}
}
function startPacedLoop(vgpu: VgpuApi, activeGpu: Gpu): { stop(): void } {
let stopped = false;
let lastPresentedAt: number | undefined;
const tick = (timestamp: number): void => {
if (stopped) return;
if (
lastPresentedAt === undefined ||
timestamp - lastPresentedAt >= MIN_FRAME_INTERVAL_MS
) {
lastPresentedAt = timestamp;
try {
vgpu.frame(activeGpu, renderFrame);
} catch (error) {
handleFailure(error);
}
}
if (!stopped) frameHandle = requestAnimationFrame(tick);
};
let frameHandle = requestAnimationFrame(tick);
return {
stop(): void {
stopped = true;
cancelAnimationFrame(frameHandle);
},
};
}
const advanceAnimationTime = (now: number): number => {
animationTime +=
lastFrameAt === undefined ? 0 : Math.max(0, (now - lastFrameAt) / 1000);
lastFrameAt = now;
return animationTime;
};
const renderFrame = (frame: Frame): void => {
if (disposed || !effects || !targets || !surface) return;
const now = clockMs();
const runBake = forceBake;
forceBake = false;
if (runBake) setBakeUniforms(effects, targets, settings);
setShadeUniforms(
effects,
targets,
settings,
advanceAnimationTime(now),
advanceSceneYaw(now)
);
renderChain(frame, effects, targets, surface, runBake);
};
const advanceSceneYaw = (now: number): number => {
if (settings.mouseYaw <= 0) {
currentSceneYaw = 0;
lastYawAt = now;
return 0;
}
const dt =
lastYawAt === undefined
? 0
: Math.min(Math.max((now - lastYawAt) / 1000, 0), MAX_FRAME_DT_S);
lastYawAt = now;
const target = pointerXNormalized * Math.max(0, settings.mouseYaw);
currentSceneYaw +=
(target - currentSceneYaw) * (1 - Math.exp(-dt / SCENE_YAW_TAU_S));
return currentSceneYaw;
};
const applyResize = () => {
resizeFrame = 0;
const size = pendingSize;
pendingSize = undefined;
if (disposed || !size || !gpu || !api || !effects || !targets || !surface)
return;
try {
const previousTargets = targets;
const nextTargets = createTargets(api, gpu, [
Math.max(1, Math.round(size.width)),
Math.max(1, Math.round(size.height)),
]);
try {
setBindings(effects, nextTargets);
setPostUniforms(effects, nextTargets, settings);
} catch (error) {
destroyTargets(nextTargets);
throw error;
}
targets = nextTargets;
destroyTargets(previousTargets);
forceBake = true;
} catch (error) {
handleFailure(error);
}
};
const resize = (size: RenderSize) => {
if (disposed || size.width <= 0 || size.height <= 0) return;
pendingSize = size;
if (!resizeFrame) resizeFrame = requestAnimationFrame(applyResize);
};
const measure = () => {
resize({
width: canvas.clientWidth,
height: canvas.clientHeight,
});
};
const dispose = () => {
if (disposed) return;
disposed = true;
loop?.stop();
if (resizeFrame) cancelAnimationFrame(resizeFrame);
observer?.disconnect();
intersection?.disconnect();
if (typeof window !== "undefined") {
mobileQuery.removeEventListener("change", onLayoutChange);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerout", onPointerOut);
window.removeEventListener("blur", recenterPointer);
document.removeEventListener("visibilitychange", onVisibilityChange);
}
gpu?.dispose();
};
const initialize = async () => {
const vgpu = await import("vgpu");
const { init } = vgpu;
if (disposed) return;
const nextGpu = await init();
if (disposed) {
nextGpu.dispose();
return;
}
gpu = nextGpu;
api = vgpu;
surface = vgpu.surface(gpu, canvas, { dpr: 1 });
effects = createEffects(vgpu, gpu);
targets = createTargets(vgpu, gpu, surface.size);
setBindings(effects, targets);
setPostUniforms(effects, targets, settings);
await prewarm(effects, targets, surface);
if (disposed) return;
observer =
typeof ResizeObserver === "undefined"
? undefined
: new ResizeObserver(measure);
observer?.observe(canvas);
window.addEventListener("pointermove", onPointerMove, { passive: true });
window.addEventListener("pointerout", onPointerOut, { passive: true });
window.addEventListener("blur", recenterPointer);
document.addEventListener("visibilitychange", onVisibilityChange);
if (typeof IntersectionObserver !== "undefined") {
intersection = new IntersectionObserver(
(entries) => {
canvasIntersecting =
entries[entries.length - 1]?.isIntersecting ?? canvasIntersecting;
reconcileLoop();
},
{ threshold: 0 }
);
intersection.observe(canvas);
}
measure();
started = true;
documentVisible = !document.hidden;
reconcileLoop();
};
function handleFailure(error: unknown): never {
dispose();
throw error;
}
const ready = initialize().catch((error: unknown) => {
if (disposed) return;
handleFailure(error);
});
return { ready, dispose };
}
function clockMs(): number {
return typeof performance === "undefined" ? Date.now() : performance.now();
}
registry/new-york/templates/portfolio-v5/components/black-hole/pipeline.ts
import type { Effect, Frame, Gpu, Surface, Target } from "vgpu";
import bakeWgsl from "./bake.wgsl";
import bloomWgsl from "./bloom.wgsl";
import compositeWgsl from "./composite.wgsl";
import {
createNoiseVolume,
NOISE_VOLUME_SIZE,
noiseVolumeSampler,
} from "./noise-volume.mjs";
import refineWgsl from "./refine.wgsl";
import shadeWgsl from "./shade.wgsl";
import type { HeroSettings } from "./settings";
export type VgpuApi = typeof import("vgpu");
type Output = Surface | Target;
type NoiseVolume = ReturnType<typeof createNoiseVolume>;
export interface Effects {
bake: Effect;
refine: Effect;
shade: Effect;
bloomExtract: Effect;
bloomBlurH0: Effect;
bloomBlurV0: Effect;
bloomDown1: Effect;
bloomBlurH1: Effect;
bloomBlurV1: Effect;
bloomDown2: Effect;
bloomBlurH2: Effect;
bloomBlurV2: Effect;
composite: Effect;
postSampler: GPUSampler;
noiseVolume: NoiseVolume;
noiseSampler: GPUSampler;
}
export interface Targets {
gbuffer: Target;
aa: Target;
scene: Target;
bloom0: Target;
bloomPing0: Target;
bloom1: Target;
bloomPing1: Target;
bloom2: Target;
bloomPing2: Target;
}
const GBUFFER_FORMATS: readonly GPUTextureFormat[] = [
"rg32float",
"rg32float",
"rgba16float",
"rgba16float",
];
const AA_FORMATS: readonly GPUTextureFormat[] = ["rg8unorm", "rgba16float"];
const CLEAR: readonly [number, number, number, number] = [0, 0, 0, 1];
export function createEffects(vgpu: VgpuApi, gpu: Gpu): Effects {
const postSampler = vgpu.sampler(gpu, {
minFilter: "linear",
magFilter: "linear",
});
const noiseSampler = noiseVolumeSampler(vgpu, gpu);
return {
bake: vgpu.effect(gpu, bakeWgsl),
refine: vgpu.effect(gpu, refineWgsl),
shade: vgpu.effect(gpu, shadeWgsl),
bloomExtract: vgpu.effect(gpu, bloomWgsl),
bloomBlurH0: vgpu.effect(gpu, bloomWgsl),
bloomBlurV0: vgpu.effect(gpu, bloomWgsl),
bloomDown1: vgpu.effect(gpu, bloomWgsl),
bloomBlurH1: vgpu.effect(gpu, bloomWgsl),
bloomBlurV1: vgpu.effect(gpu, bloomWgsl),
bloomDown2: vgpu.effect(gpu, bloomWgsl),
bloomBlurH2: vgpu.effect(gpu, bloomWgsl),
bloomBlurV2: vgpu.effect(gpu, bloomWgsl),
composite: vgpu.effect(gpu, compositeWgsl),
postSampler,
noiseSampler,
noiseVolume: createNoiseVolume(gpu, NOISE_VOLUME_SIZE),
};
}
export function createTargets(
vgpu: VgpuApi,
gpu: Gpu,
size: readonly [number, number]
): Targets {
const full = normalizeSize(size),
half = scaleSize(full, 2),
quarter = scaleSize(full, 4),
eighth = scaleSize(full, 8);
const postTarget = (targetSize: readonly [number, number]) =>
vgpu.target(gpu, {
size: targetSize,
colors: [{ format: "rgba16float" }],
});
const created: Target[] = [];
const own = (value: Target) => {
created.push(value);
return value;
};
try {
return {
gbuffer: own(
vgpu.target(gpu, {
size: full,
colors: GBUFFER_FORMATS.map((format) => ({ format })),
})
),
aa: own(
vgpu.target(gpu, {
size: full,
colors: AA_FORMATS.map((format) => ({ format })),
})
),
scene: own(postTarget(full)),
bloom0: own(postTarget(half)),
bloomPing0: own(postTarget(half)),
bloom1: own(postTarget(quarter)),
bloomPing1: own(postTarget(quarter)),
bloom2: own(postTarget(eighth)),
bloomPing2: own(postTarget(eighth)),
};
} catch (error) {
try {
destroyTargetList(created.reverse());
} catch {
// The allocation failure remains the primary error.
}
throw error;
}
}
export function destroyTargets(targets: Targets): void {
destroyTargetList([
targets.gbuffer,
targets.aa,
targets.scene,
targets.bloom0,
targets.bloomPing0,
targets.bloom1,
targets.bloomPing1,
targets.bloom2,
targets.bloomPing2,
]);
}
function destroyTargetList(values: readonly Target[]): void {
let failed = false;
let failure: unknown;
for (const value of values) {
try {
destroyTarget(value);
} catch (error) {
if (!failed) failure = error;
failed = true;
}
}
if (failed) throw failure;
}
function destroyTarget(value: Target | undefined): void {
(value as { destroy?: () => void } | undefined)?.destroy?.();
}
export function setBindings(effects: Effects, targets: Targets): void {
const [hit1, hit2, sky, view] = targets.gbuffer.colors;
const [aa, aaGeom] = targets.aa.colors;
effects.bake.set({ bake: { resolution: targets.gbuffer.size } });
effects.refine.set({
gHit1: hit1,
gSky: sky,
refine: { resolution: targets.gbuffer.size },
});
effects.shade.set({
gHit1: hit1,
gHit2: hit2,
gSky: sky,
gView: view,
gAa: aa,
gAaGeom: aaGeom,
noiseVolume: effects.noiseVolume,
noiseSampler: effects.noiseSampler,
shade: { resolution: targets.gbuffer.size },
});
const scene = targets.scene.colors[0]!,
bloom0 = targets.bloom0.colors[0]!,
bloomPing0 = targets.bloomPing0.colors[0]!,
bloom1 = targets.bloom1.colors[0]!,
bloomPing1 = targets.bloomPing1.colors[0]!,
bloom2 = targets.bloom2.colors[0]!,
bloomPing2 = targets.bloomPing2.colors[0]!;
effects.bloomExtract.set({
source: scene,
linearSampler: effects.postSampler,
});
effects.bloomBlurH0.set({
source: bloom0,
linearSampler: effects.postSampler,
});
effects.bloomBlurV0.set({
source: bloomPing0,
linearSampler: effects.postSampler,
});
effects.bloomDown1.set({
source: bloom0,
linearSampler: effects.postSampler,
});
effects.bloomBlurH1.set({
source: bloom1,
linearSampler: effects.postSampler,
});
effects.bloomBlurV1.set({
source: bloomPing1,
linearSampler: effects.postSampler,
});
effects.bloomDown2.set({
source: bloom1,
linearSampler: effects.postSampler,
});
effects.bloomBlurH2.set({
source: bloom2,
linearSampler: effects.postSampler,
});
effects.bloomBlurV2.set({
source: bloomPing2,
linearSampler: effects.postSampler,
});
effects.composite.set({
scene,
bloomNear: bloom0,
bloomMedium: bloom1,
bloomFar: bloom2,
linearSampler: effects.postSampler,
});
}
export function setBakeUniforms(
effects: Effects,
targets: Targets,
settings: HeroSettings
): void {
const geometry = {
resolution: targets.gbuffer.size,
yaw: 0,
pitch: settings.cameraY,
orbitRadius: settings.distance,
diskOuter: settings.diskRadius,
fov: settings.fov,
centerX: settings.centerX,
centerY: settings.centerY,
roll: settings.cameraRoll,
};
effects.bake.set({ bake: geometry });
effects.refine.set({ refine: geometry });
}
export function setShadeUniforms(
effects: Effects,
targets: Targets,
settings: HeroSettings,
time: number,
sceneYaw: number
): void {
effects.shade.set({
shade: {
resolution: targets.gbuffer.size,
time,
diskOuter: settings.diskRadius,
sceneYaw,
centerFade: settings.centerFade,
},
disk: settings.disk,
stars: settings.stars,
});
}
export function setPostUniforms(
effects: Effects,
targets: Targets,
settings: HeroSettings
): void {
const threshold = Math.max(0, settings.bloom.threshold),
knee = Math.max(0.0001, settings.bloom.knee),
radius = Math.max(0.1, settings.bloom.radius);
const downsample = (
sourceSize: readonly [number, number],
applyThreshold: boolean
) => ({
sourceSize,
direction: [0, 0],
params: [applyThreshold ? threshold : -1, knee, radius, 0],
});
const blur = (
sourceSize: readonly [number, number],
x: number,
y: number
) => ({
sourceSize,
direction: [x, y],
params: [-1, knee, radius, 1],
});
effects.bloomExtract.set({ bloom: downsample(targets.scene.size, true) });
effects.bloomBlurH0.set({ bloom: blur(targets.bloom0.size, 1, 0) });
effects.bloomBlurV0.set({ bloom: blur(targets.bloomPing0.size, 0, 1) });
effects.bloomDown1.set({ bloom: downsample(targets.bloom0.size, false) });
effects.bloomBlurH1.set({ bloom: blur(targets.bloom1.size, 1, 0) });
effects.bloomBlurV1.set({ bloom: blur(targets.bloomPing1.size, 0, 1) });
effects.bloomDown2.set({ bloom: downsample(targets.bloom1.size, false) });
effects.bloomBlurH2.set({ bloom: blur(targets.bloom2.size, 1, 0) });
effects.bloomBlurV2.set({ bloom: blur(targets.bloomPing2.size, 0, 1) });
effects.composite.set({
composite: {
params: [Math.max(0, settings.bloom.strength), 0, 0, 0],
},
});
}
export async function prewarm(
effects: Effects,
targets: Targets,
output: Output
): Promise<void> {
const bloomOutput = { colors: [targets.bloom0.format] };
await Promise.all([
effects.bake.compile(targets.gbuffer),
effects.refine.compile(targets.aa),
effects.shade.compile(targets.scene),
effects.bloomExtract.compile(bloomOutput),
effects.bloomBlurH0.compile(bloomOutput),
effects.bloomBlurV0.compile(bloomOutput),
effects.bloomDown1.compile(bloomOutput),
effects.bloomBlurH1.compile(bloomOutput),
effects.bloomBlurV1.compile(bloomOutput),
effects.bloomDown2.compile(bloomOutput),
effects.bloomBlurH2.compile(bloomOutput),
effects.bloomBlurV2.compile(bloomOutput),
effects.composite.compile({ colors: [output.format] }),
]);
}
export function renderChain(
frame: Frame,
effects: Effects,
targets: Targets,
output: Output,
bake: boolean
): void {
if (bake) {
frame.pass({ target: targets.gbuffer, clear: CLEAR }, (pass) =>
pass.draw(effects.bake)
);
frame.pass({ target: targets.aa, clear: CLEAR }, (pass) =>
pass.draw(effects.refine)
);
}
frame.pass({ target: targets.scene, clear: CLEAR }, (pass) =>
pass.draw(effects.shade)
);
frame.pass({ target: targets.bloom0, clear: CLEAR }, (pass) =>
pass.draw(effects.bloomExtract)
);
frame.pass({ target: targets.bloomPing0, clear: CLEAR }, (pass) =>
pass.draw(effects.bloomBlurH0)
);
frame.pass({ target: targets.bloom0, clear: CLEAR }, (pass) =>
pass.draw(effects.bloomBlurV0)
);
frame.pass({ target: targets.bloom1, clear: CLEAR }, (pass) =>
pass.draw(effects.bloomDown1)
);
frame.pass({ target: targets.bloomPing1, clear: CLEAR }, (pass) =>
pass.draw(effects.bloomBlurH1)
);
frame.pass({ target: targets.bloom1, clear: CLEAR }, (pass) =>
pass.draw(effects.bloomBlurV1)
);
frame.pass({ target: targets.bloom2, clear: CLEAR }, (pass) =>
pass.draw(effects.bloomDown2)
);
frame.pass({ target: targets.bloomPing2, clear: CLEAR }, (pass) =>
pass.draw(effects.bloomBlurH2)
);
frame.pass({ target: targets.bloom2, clear: CLEAR }, (pass) =>
pass.draw(effects.bloomBlurV2)
);
frame.pass({ target: output, clear: CLEAR }, (pass) =>
pass.draw(effects.composite)
);
}
function normalizeSize(size: readonly [number, number]): [number, number] {
return [Math.max(1, Math.floor(size[0])), Math.max(1, Math.floor(size[1]))];
}
function scaleSize(
size: readonly [number, number],
divisor: number
): [number, number] {
return [
Math.max(1, Math.floor(size[0] / divisor)),
Math.max(1, Math.floor(size[1] / divisor)),
];
}
registry/new-york/templates/portfolio-v5/components/black-hole/settings.ts
export interface DiskLook {
brightness: number;
speed: number;
stretch: number;
detail: number;
turbulence: number;
density: number;
doppler: number;
cloudScale: number;
cloudSpeed: number;
cloudStrength: number;
spare0: number;
spare1: number;
spare2: number;
spare3: number;
}
export interface StarLook {
brightness: number;
density: number;
contrast: number;
warmth: number;
twinkle: number;
}
export interface BloomLook {
strength: number;
threshold: number;
knee: number;
radius: number;
}
export interface HeroSettings {
cameraY: number;
distance: number;
diskRadius: number;
fov: number;
centerX: number;
centerY: number;
cameraRoll: number;
mouseYaw: number;
centerFade: number;
bloom: BloomLook;
disk: DiskLook;
stars: StarLook;
}
/** Shared deterministic production defaults for the browser and headless renderer. */
export function defaultHeroSettings(): HeroSettings {
return {
cameraY: 0.16,
distance: 13.5,
diskRadius: 9,
fov: 3,
centerX: 0.8,
centerY: 0.3,
cameraRoll: -0.27,
mouseYaw: 0.15,
centerFade: 0,
bloom: { strength: 1, threshold: 0, knee: 0.18, radius: 1.5 },
disk: {
brightness: 0.75,
speed: 0.75,
stretch: 5.75,
detail: 3.44,
turbulence: 4.46,
density: 1.38,
doppler: 1.21,
cloudScale: 20,
cloudSpeed: 0.3,
cloudStrength: 0.2,
spare0: 0.43,
spare1: -0.25,
spare2: -0.67,
spare3: 0.69,
},
stars: { brightness: 1, density: 1, contrast: 13, warmth: 0.5, twinkle: 0 },
};
}
registry/new-york/templates/portfolio-v5/components/black-hole/noise-volume.mjs
// Deterministic tiled value-noise lattice shared by the disk shader and Node rendering.
export const NOISE_VOLUME_SIZE = 64;
const FORMAT = "r8unorm";
const SEED = 13;
const fr = Math.fround;
const fract = (value) => fr(value - Math.floor(value));
const K0 = fr(0.1031);
const K1 = fr(0.103);
const K2 = fr(0.0973);
const K3 = fr(33.33);
/** @param {number} x @param {number} y @param {number} z */
function hash31(x, y, z) {
let qx = fract(fr(x * K0));
let qy = fract(fr(y * K1));
let qz = fract(fr(z * K2));
const d = fr(
fr(fr(qx * fr(qy + K3)) + fr(qy * fr(qz + K3))) + fr(qz * fr(qx + K3))
);
qx = fr(qx + d);
qy = fr(qy + d);
qz = fr(qz + d);
return fract(fr(fr(qx + qy) * qz));
}
/** Use signed coordinates so the disk's angular axes match the original analytic noise. */
function latticeCoord(index, size) {
return index < size / 2 ? index : index - size;
}
function buildNoiseVolume(size, seed) {
const data = new Uint8Array(size * size * size);
const offset = seed * 1024;
let cursor = 0;
for (let z = 0; z < size; z++) {
const pz = latticeCoord(z, size) + offset;
for (let y = 0; y < size; y++) {
const py = latticeCoord(y, size);
for (let x = 0; x < size; x++) {
data[cursor++] = Math.min(
255,
Math.round(hash31(latticeCoord(x, size), py, pz) * 255)
);
}
}
}
return data;
}
const cache = new Map();
function noiseVolumeData(size, seed) {
const key = `${size}:${seed}`;
let data = cache.get(key);
if (!data) {
data = buildNoiseVolume(size, seed);
cache.set(key, data);
}
return data;
}
/**
* @param {import("vgpu").Gpu} gpu
* @param {number} [size]
* @param {string} [label]
*/
export function createNoiseVolume(
gpu,
size = NOISE_VOLUME_SIZE,
label = "black-hole-noise"
) {
const texture = gpu.device.createTexture({
size: [size, size, size],
// vgpu Device.createTexture takes `dimension` (WebGPU-style), not `kind`.
dimension: "3d",
format: FORMAT,
usage: ["texture_binding", "copy_dst"],
label,
});
try {
gpu.gpu.queue.writeTexture(
{ texture: texture.gpu },
noiseVolumeData(size, SEED),
{ offset: 0, bytesPerRow: size, rowsPerImage: size },
{ width: size, height: size, depthOrArrayLayers: size }
);
return texture;
} catch (error) {
try {
texture.destroy();
} catch {
// Preserve the upload failure that made this texture unusable.
}
throw error;
}
}
/** @param {typeof import("vgpu")} vgpu @param {import("vgpu").Gpu} gpu */
export function noiseVolumeSampler(vgpu, gpu) {
return vgpu.sampler(gpu, {
addressModeU: "repeat",
addressModeV: "repeat",
addressModeW: "repeat",
minFilter: "linear",
magFilter: "linear",
});
}
registry/new-york/templates/portfolio-v5/components/black-hole/bake.wgsl
// One-shot geodesic bake: store two disk crossings, the lensed sky, and view directions.
import { TraceResult, cameraRay, escapeRadiusFor, traceRay } from "./geodesic.wgsl";
struct Bake {
resolution: vec2f,
yaw: f32,
pitch: f32,
orbitRadius: f32,
diskOuter: f32,
fov: f32,
centerX: f32,
centerY: f32,
roll: f32,
}
@group(0) @binding(0) var<uniform> bake: Bake;
const FLAG_HOLE: f32 = 1.0;
const FLAG_ESCAPED: f32 = 2.0;
struct GBuffer {
@location(0) hit1: vec2f,
@location(1) hit2: vec2f,
@location(2) sky: vec4f,
@location(3) view: vec4f,
}
@fragment fn fs_main(@location(0) uv: vec2f) -> GBuffer {
let ray = cameraRay(
uv,
bake.resolution,
bake.yaw,
bake.pitch,
bake.orbitRadius,
bake.fov,
bake.centerX,
bake.centerY,
bake.roll,
);
var traced = traceRay(ray.position, ray.velocity, bake.diskOuter, escapeRadiusFor(bake.orbitRadius));
if (traced.swallowed < 0.5 && traced.escaped < 0.5) {
traced.swallowed = 1.0;
}
return GBuffer(
traced.hit1Plane,
traced.hit2Plane,
vec4f(traced.finalVelocity, traced.swallowed * FLAG_HOLE + traced.escaped * FLAG_ESCAPED),
vec4f(traced.hit1Direction, traced.hit2Direction),
);
}
registry/new-york/templates/portfolio-v5/components/black-hole/bloom.wgsl
// HDR bloom downsample and separable Gaussian blur.
struct Bloom {
sourceSize: vec2f,
direction: vec2f,
params: vec4f,
}
@group(0) @binding(0) var<uniform> bloom: Bloom;
@group(0) @binding(1) var source: texture_2d<f32>;
@group(0) @binding(2) var linearSampler: sampler;
fn softThreshold(color: vec3f) -> vec3f {
let threshold = bloom.params.x;
if (threshold <= 0.0) {
return color;
}
let brightness = dot(color, vec3f(0.2126, 0.7152, 0.0722));
let knee = max(min(bloom.params.y, threshold), 0.000001);
let soft = clamp(brightness - threshold + knee, 0.0, 2.0 * knee);
let softContribution = soft * soft / (4.0 * knee + 0.0001);
let contribution = max(brightness - threshold, softContribution) / max(brightness, 0.0001);
return color * contribution;
}
fn downsample(uv: vec2f) -> vec3f {
let texel = 1.0 / bloom.sourceSize;
let offset = texel * 0.5;
let color = (
textureSample(source, linearSampler, uv + vec2f(-offset.x, -offset.y)).rgb +
textureSample(source, linearSampler, uv + vec2f( offset.x, -offset.y)).rgb +
textureSample(source, linearSampler, uv + vec2f(-offset.x, offset.y)).rgb +
textureSample(source, linearSampler, uv + vec2f( offset.x, offset.y)).rgb
) * 0.25;
return softThreshold(color);
}
fn gaussianBlur(uv: vec2f) -> vec3f {
let sigma = max(bloom.params.z, 0.5);
let inverseTwoSigmaSquared = 0.5 / (sigma * sigma);
let w0 = 1.0;
let w1 = exp(-1.0 * inverseTwoSigmaSquared);
let w2 = exp(-4.0 * inverseTwoSigmaSquared);
let w3 = exp(-9.0 * inverseTwoSigmaSquared);
let w4 = exp(-16.0 * inverseTwoSigmaSquared);
let pair12 = w1 + w2;
let pair34 = w3 + w4;
let offset12 = (w1 + 2.0 * w2) / max(pair12, 0.000001);
let offset34 = (3.0 * w3 + 4.0 * w4) / max(pair34, 0.000001);
let normalization = w0 + 2.0 * (pair12 + pair34);
let texel = bloom.direction / bloom.sourceSize;
var color = textureSample(source, linearSampler, uv).rgb * w0;
color += textureSample(source, linearSampler, uv + texel * offset12).rgb * pair12;
color += textureSample(source, linearSampler, uv - texel * offset12).rgb * pair12;
color += textureSample(source, linearSampler, uv + texel * offset34).rgb * pair34;
color += textureSample(source, linearSampler, uv - texel * offset34).rgb * pair34;
return color / normalization;
}
@fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
var color: vec3f;
if (bloom.params.w > 0.5) {
color = gaussianBlur(uv);
} else {
color = downsample(uv);
}
return vec4f(color, 1.0);
}
registry/new-york/templates/portfolio-v5/components/black-hole/composite.wgsl
// Combine bloom levels, tone map, vignette, and convert to display output.
struct Composite {
params: vec4f,
}
@group(0) @binding(0) var<uniform> composite: Composite;
@group(0) @binding(1) var scene: texture_2d<f32>;
@group(0) @binding(2) var bloomNear: texture_2d<f32>;
@group(0) @binding(3) var bloomMedium: texture_2d<f32>;
@group(0) @binding(4) var bloomFar: texture_2d<f32>;
@group(0) @binding(5) var linearSampler: sampler;
const EXPOSURE: f32 = 1.15;
const SATURATION: f32 = 0.0;
fn aces(x: vec3f) -> vec3f {
let a = 2.51;
let b = 0.03;
let c = 2.43;
let d = 0.59;
let e = 0.14;
return clamp((x * (a * x + vec3f(b))) / (x * (c * x + vec3f(d)) + vec3f(e)), vec3f(0.0), vec3f(1.0));
}
fn tonemap(linearColor: vec3f, uv: vec2f) -> vec3f {
var color = aces(linearColor * EXPOSURE);
let centered = uv - vec2f(0.5);
let vignette = 1.0 - smoothstep(0.55, 1.15, length(centered) * 1.6);
color *= mix(0.72, 1.0, vignette);
color = pow(color, vec3f(1.0 / 2.2));
let luma = dot(color, vec3f(0.2126, 0.7152, 0.0722));
return mix(vec3f(luma), color, SATURATION);
}
@fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
let sceneColor = textureSample(scene, linearSampler, uv).rgb;
let bloom =
textureSample(bloomNear, linearSampler, uv).rgb * 0.50 +
textureSample(bloomMedium, linearSampler, uv).rgb * 0.32 +
textureSample(bloomFar, linearSampler, uv).rgb * 0.18;
let hdr = sceneColor + bloom * composite.params.x;
return vec4f(tonemap(hdr, uv), 1.0);
}
registry/new-york/templates/portfolio-v5/components/black-hole/disk.wgsl
// Accretion-disk material with deterministic tiled noise and radial prefiltering.
import { GBufferSample, HORIZON, ISCO } from "./gbuffer.wgsl";
export struct DiskLook {
brightness: f32,
speed: f32,
stretch: f32,
detail: f32,
turbulence: f32,
density: f32,
doppler: f32,
cloudScale: f32,
cloudSpeed: f32,
cloudStrength: f32,
spare0: f32,
spare1: f32,
spare2: f32,
spare3: f32,
}
export struct DiskSample {
color: vec3f,
alpha: f32,
}
struct NoiseLattice {
invSize: f32,
}
fn noise3(tex: texture_3d<f32>, samp: sampler, lattice: NoiseLattice, p: vec3f) -> f32 {
let i = floor(p);
let f = p - i;
let u = f * f * (3.0 - 2.0 * f);
return textureSampleLevel(tex, samp, (i + u + vec3f(0.5)) * lattice.invSize, 0.0).r;
}
fn streakFbm(
tex: texture_3d<f32>,
samp: sampler,
lattice: NoiseLattice,
angle: f32,
radius: f32,
angScale: f32,
radScale: f32,
octaves: i32,
dAngle: f32,
dRadius: f32,
lacAng: f32,
lacRad: f32,
seed: f32,
) -> f32 {
var value: f32 = 0.0;
var total: f32 = 0.0;
var amplitude: f32 = 0.5;
var a = angScale;
var r = radScale;
var offset = seed;
for (var i = 0; i < octaves; i++) {
let visible = clamp(1.0 - 1.7 * max(dAngle * a, dRadius * r), 0.0, 1.0);
var sampleValue: f32 = 0.5;
if (visible > 0.004) {
sampleValue = mix(
0.5,
noise3(tex, samp, lattice, vec3f(cos(angle) * a, sin(angle) * a, radius * r + offset)),
visible,
);
}
value += amplitude * sampleValue;
total += amplitude;
a *= lacAng;
r *= lacRad;
offset += 23.7;
amplitude *= 0.55;
}
return value / max(total, 0.0001);
}
fn ridgeFbm(
tex: texture_3d<f32>,
samp: sampler,
lattice: NoiseLattice,
angle: f32,
radius: f32,
angScale: f32,
radScale: f32,
octaves: i32,
dAngle: f32,
dRadius: f32,
lacAng: f32,
lacRad: f32,
seed: f32,
) -> f32 {
var value: f32 = 0.0;
var total: f32 = 0.0;
var amplitude: f32 = 0.5;
var a = angScale;
var r = radScale;
var offset = seed;
for (var i = 0; i < octaves; i++) {
let visible = clamp(1.0 - 1.7 * max(dAngle * a, dRadius * r), 0.0, 1.0);
var crest: f32 = 0.42;
if (visible > 0.004) {
let n = noise3(tex, samp, lattice, vec3f(cos(angle) * a, sin(angle) * a, radius * r + offset));
crest = mix(0.42, pow(1.0 - abs(n * 2.0 - 1.0), 1.35), visible);
}
value += amplitude * crest;
total += amplitude;
a *= lacAng;
r *= lacRad;
offset += 41.9;
amplitude *= 0.62;
}
return value / max(total, 0.0001);
}
struct FieldParams {
angBase: f32,
radBase: f32,
flowRad: f32,
chaos: f32,
outward: f32,
dAngle: f32,
dRadius: f32,
}
fn smokeField(
tex: texture_3d<f32>, samp: sampler, lattice: NoiseLattice,
angle: f32, radius: f32, p: FieldParams,
) -> vec2f {
let warpA = (streakFbm(
tex, samp, lattice, angle, radius, p.angBase * 0.55, p.flowRad * 1.6,
2, p.dAngle, p.dRadius, 1.6, 2.0, 3.7,
)) - 0.5;
let warpB = (streakFbm(
tex, samp, lattice, angle + 2.4, radius * 1.13,
p.angBase * 2.8, p.radBase * 0.45, 3, p.dAngle, p.dRadius,
1.7, 2.0, 61.3,
)) - 0.5;
let radiusW = radius + (warpA * 1.9 + warpB * 1.25 * p.outward) * p.chaos;
let angleW = angle + (warpB * 0.9 - warpA * 0.35) * p.chaos * 0.55 / max(radius * 0.22, 0.35);
let flow = streakFbm(
tex, samp, lattice, angleW, radiusW, p.angBase, p.flowRad,
3, p.dAngle, p.dRadius, 2.0, 1.12, 131.7,
);
let threads = ridgeFbm(
tex, samp, lattice, angleW, radiusW, p.angBase * 0.85, p.radBase,
5, p.dAngle, p.dRadius, 1.26, 2.05, 0.0,
);
let fineVis = clamp(1.0 - 1.7 * max(p.dAngle * p.angBase * 0.85, p.dRadius * p.radBase), 0.0, 1.0);
let field = mix(flow, flow * 0.22 + threads * 1.05, fineVis);
let rim = (warpA + warpB * 0.5) * 0.9;
return vec2f(f32(field), rim);
}
const FIELD_MEAN = 0.52;
const SHEAR_REF_RADIUS = 6.5;
export const SHEAR_PERIOD: f32 = 10.0;
const TWO_PI = 6.283185307;
export fn shadeDisk(
g: GBufferSample,
look: DiskLook,
time: f32,
footprint: f32,
noiseTex: texture_3d<f32>,
noiseSampler: sampler,
) -> DiskSample {
var lattice: NoiseLattice;
lattice.invSize = 1.0 / f32(textureDimensions(noiseTex).x);
let plane = vec2f(g.position.x, g.position.z);
let radius = g.diskPolar.x;
let azimuth = g.diskPolar.y;
let radiusNorm = clamp(g.diskUv.x, 0.0, 1.0);
let viewDirection = g.viewDirection;
let slant = max(abs(viewDirection.y), 0.022);
let grazing = min(1.0 / slant, 34.0);
let viewPlane = normalize(vec2f(viewDirection.x, viewDirection.z) + vec2f(1e-6, 0.0));
let radialDir = normalize(plane + vec2f(1e-6, 0.0));
let alignR = clamp(abs(dot(radialDir, viewPlane)), 0.0, 1.0);
let alignT = sqrt(max(1.0 - alignR * alignR, 0.0));
let stretchSq = grazing * grazing - 1.0;
let kR = sqrt(1.0 + stretchSq * alignR * alignR); // radial elongation
let kT = sqrt(1.0 + stretchSq * alignT * alignT); // tangential elongation
let baseScaleR = max(look.detail, 0.05);
let baseScaleA = max(look.stretch, 0.05);
let pixelWorld = footprint / max(baseScaleR * kR, baseScaleA * kT / max(radius, ISCO));
let dRadius = pixelWorld * kR;
let dAngle = pixelWorld * kT / max(radius, ISCO);
let omega = look.speed * 0.55 / pow(radius, 1.5);
let omegaRef = look.speed * 0.55 / pow(SHEAR_REF_RADIUS, 1.5);
let dOmega = omega - omegaRef;
let rigid = fract(time * omegaRef / TWO_PI) * TWO_PI;
let swirl = max(0.0, 0.85 + look.spare1);
let flowBase = azimuth - rigid + swirl * log(radius / ISCO);
let cycle = time / SHEAR_PERIOD;
let u0 = fract(cycle);
let u1 = fract(cycle + 0.5);
let shear0 = (u0 - 0.5) * SHEAR_PERIOD;
let shear1 = (u1 - 0.5) * SHEAR_PERIOD;
let w0 = 1.0 - abs(2.0 * u0 - 1.0);
let w1 = 1.0 - w0;
let angle0 = flowBase - dOmega * shear0;
let angle1 = flowBase - dOmega * shear1;
let outward = smoothstep(0.0, 0.92, radiusNorm);
let fray = max(0.0, 1.0 + look.spare3);
let chaos = look.turbulence * (0.08 + 2.10 * outward * outward) * fray;
let angBase = max(look.stretch, 0.05) * 0.45 * (0.80 + 1.45 * outward * fray);
let radBase = max(look.detail, 0.05) * 2.35;
let flowRad = max(look.detail, 0.05) * 0.105;
var params: FieldParams;
params.angBase = angBase;
params.radBase = radBase;
params.flowRad = flowRad;
params.chaos = chaos;
params.outward = outward;
params.dAngle = dAngle;
params.dRadius = dRadius;
let lobeShift = abs(dOmega) * SHEAR_PERIOD * 0.5 * angBase * 0.85;
let rho = 1.0 - smoothstep(0.12, 1.1, lobeShift);
var blended: vec2f;
var lobeVariance = 1.0;
if (rho > 0.98) {
let angleMerged = mix(angle1, angle0, w0);
blended = smokeField(noiseTex, noiseSampler, lattice, angleMerged, radius, params);
} else {
let lobe0 = smokeField(noiseTex, noiseSampler, lattice, angle0, radius, params);
let lobe1 = smokeField(noiseTex, noiseSampler, lattice, angle1, radius, params);
blended = mix(lobe1, lobe0, w0);
lobeVariance = sqrt(max(w0 * w0 + w1 * w1 + 2.0 * rho * w0 * w1, 0.25));
}
var field = FIELD_MEAN + (blended.x - FIELD_MEAN) / lobeVariance;
let cloudRate = omegaRef * look.cloudSpeed;
let cloudRigid = fract(time * cloudRate / TWO_PI) * TWO_PI;
let cloudAngle = azimuth - cloudRigid + 0.32 * log(radius / ISCO);
let cloudScale = max(look.cloudScale, 0.05);
let cloudRaw = streakFbm(
noiseTex,
noiseSampler,
lattice,
cloudAngle,
radius,
cloudScale,
cloudScale * 0.34,
2,
dAngle,
dRadius,
1.72,
1.86,
211.7,
);
let cloud = smoothstep(0.28, 0.72, cloudRaw);
let cloudStrength = clamp(look.cloudStrength, 0.0, 0.95);
let cloudMultiplier = mix(1.0 - cloudStrength, 1.0 + cloudStrength, cloud);
field *= cloudMultiplier;
let rimNoise = blended.y;
let innerEdge = smoothstep(0.0, 0.055, radiusNorm);
let outerEdge = 1.0 - smoothstep(0.42 + rimNoise * 0.30 * fray, 1.0, radiusNorm);
let envelope = innerEdge * outerEdge * mix(1.0, 0.62, outward);
let contrast = max(0.2, 1.0 + look.spare2);
let lo = 0.50 - 0.16 / contrast;
let hi = 0.50 + 0.21 / contrast;
var smoke = clamp(pow(smoothstep(lo, hi, field), 1.0 + 0.9 * contrast) * envelope, 0.0, 1.0);
let fieldN = clamp((field - (lo - 0.10)) / max(hi - lo + 0.26, 0.02), 0.0, 1.0);
let emissivity = (mix(0.05, 1.0, pow(fieldN, 1.35)) + 2.2 * pow(fieldN, 5.0)) * envelope;
let path = pow(grazing, 0.62);
let thickness = mix(0.30, 0.85, radiusNorm);
let opticalDepth = smoke * thickness * path * look.density * 0.95;
let coverage = 1.0 - exp(-opticalDepth);
let heat = pow(1.0 - radiusNorm, 1.25);
var thermal = mix(vec3f(0.52, 0.14, 0.03), vec3f(1.0, 0.56, 0.17), smoothstep(0.03, 0.5, heat));
thermal = mix(thermal, vec3f(1.0, 0.94, 0.83), pow(heat, 2.2));
let tangent = normalize(vec3f(-plane.y, 0.0, plane.x));
let orbitalSpeed = min(0.64, 0.94 / sqrt(max(radius - HORIZON, 0.25)));
let towardObserver = dot(tangent, -normalize(viewDirection));
let beaming = pow(clamp(1.0 / (1.0 - orbitalSpeed * towardObserver), 0.72, 1.55), 1.5 * look.doppler);
let redshift = sqrt(max(1.0 - HORIZON / radius, 0.025));
let facing = mix(0.82, 1.0, step(0.0, g.side));
let flux = pow(clamp(ISCO / radius, 0.0, 1.0), 1.7);
let core = 1.0 + 2.6 * pow(1.0 - radiusNorm, 5.0);
let arcLift = max(0.0, 1.0 + look.spare0);
let faceOn = smoothstep(0.16, 0.75, abs(viewDirection.y));
let lift = 1.0 + 1.55 * arcLift * faceOn;
let edgeGlow = 1.0 + 0.55 * smoothstep(6.0, 26.0, grazing);
let source = thermal * beaming * redshift * facing * flux * lift * edgeGlow * core * emissivity;
let emission = source * look.brightness * 1.35;
var sample: DiskSample;
sample.color = vec3f(emission);
sample.alpha = coverage;
return sample;
}
registry/new-york/templates/portfolio-v5/components/black-hole/gbuffer.wgsl
// Shared decoding contract for the baked crossings consumed by the frame shader.
export const HORIZON: f32 = 1.0;
export const ISCO: f32 = 3.0;
export const TAU: f32 = 6.28318530718;
export const PI_CONST: f32 = 3.14159265359;
export struct GBufferSample {
position: vec3f,
normal: vec3f,
diskUv: vec2f,
diskPolar: vec2f,
rayDirection: vec3f,
viewDirection: vec3f,
side: f32,
coverage: f32,
span: f32,
isHit: bool,
synthesized: bool,
isBlackHole: bool,
escaped: bool,
}
export struct GBufferLayers {
front: GBufferSample,
back: GBufferSample,
}
fn decodeDirection(encoded: vec2f) -> vec3f {
let horizontal = sqrt(max(1.0 - encoded.x * encoded.x, 0.0));
return vec3f(cos(encoded.y) * horizontal, encoded.x, sin(encoded.y) * horizontal);
}
fn decodeLayer(
plane: vec2f, encodedDirection: vec2f, sky: vec4f, flags: i32,
diskOuter: f32, aa: vec2f, synthesized: bool,
) -> GBufferSample {
var sample: GBufferSample;
let planeRadius = length(plane);
let isHit = planeRadius > ISCO * 0.5;
let radius = max(planeRadius, ISCO);
let azimuth = atan2(plane.y, plane.x);
let direction = decodeDirection(encodedDirection);
let side = select(1.0, -1.0, direction.y > 0.0);
sample.position = select(vec3f(0.0), vec3f(plane.x, 0.0, plane.y), isHit);
sample.normal = select(vec3f(0.0), vec3f(0.0, side, 0.0), isHit);
sample.diskUv = vec2f(
clamp((radius - ISCO) / max(diskOuter - ISCO, 0.001), 0.0, 1.0),
(azimuth + PI_CONST) / TAU,
);
sample.diskPolar = vec2f(radius, azimuth);
sample.rayDirection = sky.xyz;
sample.viewDirection = direction;
sample.side = select(0.0, side, isHit);
sample.coverage = clamp(aa.x, 0.0, 1.0);
sample.span = clamp(aa.y, 0.0, 1.0);
sample.isHit = isHit;
sample.synthesized = synthesized && isHit;
sample.isBlackHole = (flags & 1) != 0;
sample.escaped = (flags & 2) != 0;
return sample;
}
export fn decodeGBuffer(
hit1: vec2f, hit2: vec2f, sky: vec4f, view: vec4f,
diskOuter: f32, aa: vec2f, aaGeom: vec4f,
) -> GBufferLayers {
let flags = i32(sky.w + 0.5);
let substitute = length(hit1) <= ISCO * 0.5 && length(aaGeom.xy) > ISCO * 0.5;
let frontPlane = select(hit1, aaGeom.xy, substitute);
let frontDirection = select(view.xy, aaGeom.zw, substitute);
var layers: GBufferLayers;
layers.front = decodeLayer(frontPlane, frontDirection, sky, flags, diskOuter, aa, substitute);
layers.back = decodeLayer(hit2, view.zw, sky, flags, diskOuter, vec2f(1.0, 0.0), false);
if (!layers.front.isHit) {
layers.back.isHit = false;
layers.back.side = 0.0;
layers.back.normal = vec3f(0.0);
}
return layers;
}
export fn sampleAtRadius(g: GBufferSample, radius: f32, diskOuter: f32) -> GBufferSample {
var moved = g;
let clamped = clamp(radius, ISCO, max(diskOuter, ISCO));
let azimuth = g.diskPolar.y;
moved.position = vec3f(cos(azimuth) * clamped, 0.0, sin(azimuth) * clamped);
moved.diskPolar = vec2f(clamped, azimuth);
moved.diskUv = vec2f(
clamp((clamped - ISCO) / max(diskOuter - ISCO, 0.001), 0.0, 1.0),
g.diskUv.y,
);
return moved;
}
registry/new-york/templates/portfolio-v5/components/black-hole/geodesic.wgsl
// Shared Schwarzschild-like ray integration used by the bake and refinement passes.
export const HORIZON: f32 = 1.0;
export const ISCO: f32 = 3.0;
export const MAX_STEPS: i32 = 768;
export struct TraceResult {
hit1Plane: vec2f,
hit1Direction: vec2f,
hit2Plane: vec2f,
hit2Direction: vec2f,
hitCount: i32,
swallowed: f32,
escaped: f32,
finalVelocity: vec3f,
}
export struct CameraRay {
position: vec3f,
velocity: vec3f,
}
export fn escapeRadiusFor(orbitRadius: f32) -> f32 {
return max(120.0, orbitRadius + 8.0);
}
export fn encodeDirection(direction: vec3f) -> vec2f {
return vec2f(direction.y, atan2(direction.z, direction.x));
}
fn geodesicAcceleration(position: vec3f, velocity: vec3f) -> vec3f {
let r2 = max(dot(position, position), 0.0001);
let angularMomentum = cross(position, velocity);
let h2 = dot(angularMomentum, angularMomentum);
return -1.5 * h2 * position / (r2 * r2 * sqrt(r2));
}
export fn cameraRay(
uv: vec2f,
resolution: vec2f,
yaw: f32,
pitch: f32,
orbitRadius: f32,
fov: f32,
centerX: f32,
centerY: f32,
roll: f32,
) -> CameraRay {
let aspect = resolution.x / max(resolution.y, 1.0);
let ndc = vec2f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
let screenPlane = (ndc - vec2f(centerX, centerY)) * vec2f(aspect, 1.0);
let cosine = cos(roll);
let sine = sin(roll);
let screen = vec2f(
screenPlane.x * cosine - screenPlane.y * sine,
screenPlane.x * sine + screenPlane.y * cosine,
);
let clampedPitch = clamp(pitch, -1.319, 1.319);
let cameraPosition = vec3f(
sin(yaw) * cos(clampedPitch) * orbitRadius,
sin(clampedPitch) * orbitRadius,
cos(yaw) * cos(clampedPitch) * orbitRadius,
);
let forward = normalize(vec3f(0.0) - cameraPosition);
let right = normalize(cross(forward, vec3f(0.0, 1.0, 0.0)));
let up = cross(right, forward);
var ray: CameraRay;
ray.position = cameraPosition;
ray.velocity = normalize(forward * fov + right * screen.x + up * screen.y);
return ray;
}
export fn traceRay(cameraPosition: vec3f, initialVelocity: vec3f, diskOuter: f32, escapeRadius: f32) -> TraceResult {
var position = cameraPosition;
var velocity = initialVelocity;
var result: TraceResult;
result.hit1Plane = vec2f(0.0);
result.hit1Direction = vec2f(0.0);
result.hit2Plane = vec2f(0.0);
result.hit2Direction = vec2f(0.0);
result.hitCount = 0;
result.swallowed = 0.0;
result.escaped = 0.0;
for (var stepIndex = 0; stepIndex < MAX_STEPS; stepIndex++) {
let radius = length(position);
if (radius < HORIZON * 1.004) {
result.swallowed = 1.0;
break;
}
if (radius > escapeRadius && dot(position, velocity) > 0.0) {
result.escaped = 1.0;
break;
}
let stepSize = clamp((radius - HORIZON) * 0.035, 0.0045, 0.075 * max(1.0, radius / 6.0));
let previousPosition = position;
let previousVelocity = velocity;
let acceleration0 = geodesicAcceleration(position, velocity);
velocity += acceleration0 * (0.5 * stepSize);
position += velocity * stepSize;
let acceleration1 = geodesicAcceleration(position, velocity);
velocity += acceleration1 * (0.5 * stepSize);
velocity = normalize(velocity);
if (result.hitCount < 2) {
let previousSide = select(-1.0, 1.0, previousPosition.y >= 0.0);
let currentSide = select(-1.0, 1.0, position.y >= 0.0);
if (previousSide != currentSide) {
let t = clamp(previousPosition.y / (previousPosition.y - position.y), 0.0, 1.0);
let crossing = mix(previousPosition, position, t);
let planeRadius = length(crossing.xz);
if (planeRadius >= ISCO && planeRadius <= diskOuter) {
let direction = encodeDirection(normalize(mix(previousVelocity, velocity, t)));
if (result.hitCount == 0) {
result.hit1Plane = crossing.xz;
result.hit1Direction = direction;
} else {
result.hit2Plane = crossing.xz;
result.hit2Direction = direction;
}
result.hitCount += 1;
}
}
}
}
result.finalVelocity = velocity;
return result;
}
registry/new-york/templates/portfolio-v5/components/black-hole/refine.wgsl
// One-shot photon-ring refinement: measure sub-pixel coverage and synthesize missed crossings.
import { HORIZON, ISCO, cameraRay, encodeDirection, escapeRadiusFor, traceRay } from "./geodesic.wgsl";
struct Refine {
resolution: vec2f,
yaw: f32,
pitch: f32,
orbitRadius: f32,
diskOuter: f32,
fov: f32,
centerX: f32,
centerY: f32,
roll: f32,
}
@group(0) @binding(0) var<uniform> refine: Refine;
@group(0) @binding(1) var gHit1: texture_2d<f32>;
@group(0) @binding(2) var gSky: texture_2d<f32>;
const SUB_STEPS: i32 = 4;
const MASK_RADIUS: i32 = 2;
const GRADIENT_LIMIT: f32 = 0.12;
const B_CRIT: f32 = 2.59807621;
const CRITICAL_BAND: f32 = 0.06;
fn isHitAt(plane: vec2f) -> bool {
return length(plane) > ISCO * 0.5;
}
struct RefineOut {
@location(0) coverage: vec2f,
@location(1) geometry: vec4f,
}
@fragment fn fs_main(@location(0) uv: vec2f) -> RefineOut {
let dimensions = vec2i(textureDimensions(gHit1, 0));
let texel = vec2i(clamp(uv * refine.resolution, vec2f(0.0), refine.resolution - vec2f(1.0)));
let annulus = max(refine.diskOuter - ISCO, 0.001);
let centerPlane = textureLoad(gHit1, texel, 0).xy;
let centerHit = isHitAt(centerPlane);
let centerHole = (i32(textureLoad(gSky, texel, 0).w + 0.5) & 1) != 0;
let centerRadiusNorm = clamp((length(centerPlane) - ISCO) / annulus, 0.0, 1.0);
let centerRay = cameraRay(
uv,
refine.resolution,
refine.yaw,
refine.pitch,
refine.orbitRadius,
refine.fov,
refine.centerX,
refine.centerY,
refine.roll,
);
let impactParameter = length(cross(centerRay.position, centerRay.velocity));
var boundary = abs(impactParameter - B_CRIT) < CRITICAL_BAND * HORIZON;
for (var dy = -MASK_RADIUS; dy <= MASK_RADIUS; dy++) {
for (var dx = -MASK_RADIUS; dx <= MASK_RADIUS; dx++) {
let neighbor = clamp(texel + vec2i(dx, dy), vec2i(0), dimensions - vec2i(1));
let plane = textureLoad(gHit1, neighbor, 0).xy;
let hit = isHitAt(plane);
let hole = (i32(textureLoad(gSky, neighbor, 0).w + 0.5) & 1) != 0;
if (hit != centerHit || hole != centerHole) {
boundary = true;
}
if (hit && centerHit) {
let radiusNorm = clamp((length(plane) - ISCO) / annulus, 0.0, 1.0);
if (abs(radiusNorm - centerRadiusNorm) > GRADIENT_LIMIT) {
boundary = true;
}
}
}
}
if (!boundary) {
return RefineOut(vec2f(select(0.0, 1.0, centerHit), 0.0), vec4f(0.0));
}
let escapeRadius = escapeRadiusFor(refine.orbitRadius);
var hits = 0.0;
var minRadius = 1e9;
var maxRadius = -1e9;
var bestPlane = vec2f(0.0);
var bestDirection = vec2f(0.0);
var bestRadius = 0.0;
var bestDistance = 1e9;
for (var sy = 0; sy < SUB_STEPS; sy++) {
for (var sx = 0; sx < SUB_STEPS; sx++) {
let offset = (vec2f(f32(sx), f32(sy)) + vec2f(0.5)) / f32(SUB_STEPS);
let subUv = (vec2f(texel) + offset) / refine.resolution;
let ray = cameraRay(
subUv,
refine.resolution,
refine.yaw,
refine.pitch,
refine.orbitRadius,
refine.fov,
refine.centerX,
refine.centerY,
refine.roll,
);
let traced = traceRay(ray.position, ray.velocity, refine.diskOuter, escapeRadius);
if (traced.hitCount > 0) {
let radius = length(traced.hit1Plane);
hits += 1.0;
minRadius = min(minRadius, radius);
maxRadius = max(maxRadius, radius);
let distance = length(offset - vec2f(0.5));
if (distance < bestDistance) {
bestDistance = distance;
bestPlane = traced.hit1Plane;
bestDirection = traced.hit1Direction;
bestRadius = radius;
}
}
}
}
let coverage = hits / f32(SUB_STEPS * SUB_STEPS);
if (hits < 0.5) {
return RefineOut(vec2f(0.0, 0.0), vec4f(0.0));
}
var r0 = length(centerPlane);
var span = 0.0;
var geometry = vec4f(0.0);
if (centerHit) {
span = 2.0 * max(abs(maxRadius - r0), abs(r0 - minRadius));
} else {
r0 = 0.5 * (minRadius + maxRadius);
span = maxRadius - minRadius;
geometry = vec4f(bestPlane * (r0 / max(bestRadius, ISCO)), bestDirection);
}
return RefineOut(vec2f(coverage, clamp(span / annulus, 0.0, 1.0)), geometry);
}
registry/new-york/templates/portfolio-v5/components/black-hole/shade.wgsl
// Per-frame shading: decode the bake, shade stars and disk layers, then composite them.
import { GBufferSample, GBufferLayers, decodeGBuffer, sampleAtRadius, ISCO, PI_CONST, TAU } from "./gbuffer.wgsl";
import { DiskLook, DiskSample, shadeDisk, SHEAR_PERIOD } from "./disk.wgsl";
import { StarLook, shadeStars } from "./stars.wgsl";
struct Shade {
resolution: vec2f,
time: f32,
diskOuter: f32,
sceneYaw: f32,
centerFade: f32,
}
const DISK_GAIN: f32 = 1.35;
fn centeredCopyFade(uvY: f32) -> f32 {
let distanceFromCenter = abs(uvY - 0.5);
return pow(smoothstep(0.08, 0.38, distanceFromCenter), 2.2);
}
@group(0) @binding(0) var<uniform> shade: Shade;
@group(0) @binding(1) var gHit1: texture_2d<f32>;
@group(0) @binding(2) var gHit2: texture_2d<f32>;
@group(0) @binding(3) var gSky: texture_2d<f32>;
@group(0) @binding(4) var gView: texture_2d<f32>;
@group(0) @binding(5) var<uniform> disk: DiskLook;
@group(0) @binding(6) var<uniform> stars: StarLook;
@group(0) @binding(7) var noiseVolume: texture_3d<f32>;
@group(0) @binding(8) var noiseSampler: sampler;
@group(0) @binding(9) var gAa: texture_2d<f32>;
@group(0) @binding(10) var gAaGeom: texture_2d<f32>;
fn diskFootprintAxes(g: GBufferSample) -> vec2f {
let angular = max(disk.stretch, 0.05);
let noiseAngle = g.diskPolar.y
- min(shade.time, SHEAR_PERIOD * 0.5) * (disk.speed * 0.55 / pow(g.diskPolar.x, 1.5));
let noiseCoords = vec3f(
cos(noiseAngle) * angular,
sin(noiseAngle) * angular,
g.diskPolar.x * disk.detail,
);
return vec2f(
max(fwidth(noiseCoords.x), fwidth(noiseCoords.y)),
fwidth(noiseCoords.z),
);
}
fn diskFootprint(axes: vec2f) -> f32 {
return min(max(axes.x, axes.y), 4.0);
}
fn rotateY(v: vec3f, angle: f32) -> vec3f {
let c = cos(angle);
let s = sin(angle);
return vec3f(c * v.x + s * v.z, v.y, -s * v.x + c * v.z);
}
fn wrapAngle(angle: f32) -> f32 {
return angle - TAU * floor((angle + PI_CONST) / TAU);
}
fn rotateSample(g: GBufferSample, angle: f32) -> GBufferSample {
var rotated = g;
rotated.position = rotateY(g.position, angle);
rotated.viewDirection = rotateY(g.viewDirection, angle);
rotated.rayDirection = rotateY(g.rayDirection, angle);
let azimuth = wrapAngle(g.diskPolar.y - angle);
rotated.diskPolar = vec2f(g.diskPolar.x, azimuth);
rotated.diskUv = vec2f(g.diskUv.x, (azimuth + PI_CONST) / TAU);
return rotated;
}
fn rotateLayers(layers: GBufferLayers, angle: f32) -> GBufferLayers {
var rotated: GBufferLayers;
rotated.front = rotateSample(layers.front, angle);
rotated.back = rotateSample(layers.back, angle);
return rotated;
}
const AA_TAPS: i32 = 6;
const AA_SPAN_MIN: f32 = 0.15;
fn shadeFront(g: GBufferSample, footprint: f32, angularFootprint: f32) -> DiskSample {
let annulus = max(shade.diskOuter - ISCO, 0.001);
let spanWorld = g.span * annulus;
if (g.span <= AA_SPAN_MIN) {
return shadeDisk(g, disk, shade.time, footprint, noiseVolume, noiseSampler);
}
let tapFootprint = min(max(angularFootprint, max(disk.detail, 0.05) * (spanWorld / f32(AA_TAPS))), 4.0);
let step = spanWorld / f32(AA_TAPS);
let start = g.diskPolar.x - spanWorld * 0.5;
var sumEmission = vec3f(0.0);
var sumAlpha = 0.0;
var taps = 0.0;
for (var i = 0; i < AA_TAPS; i++) {
let radius = start + (f32(i) + 0.5) * step;
if (radius < ISCO || radius > shade.diskOuter) {
continue;
}
let tap = shadeDisk(
sampleAtRadius(g, radius, shade.diskOuter), disk, shade.time,
tapFootprint, noiseVolume, noiseSampler,
);
sumEmission += tap.color * tap.alpha;
sumAlpha += tap.alpha;
taps += 1.0;
}
if (taps < 0.5) {
return shadeDisk(g, disk, shade.time, footprint, noiseVolume, noiseSampler);
}
var sample: DiskSample;
let meanAlpha = sumAlpha / taps;
sample.alpha = meanAlpha;
sample.color = select(vec3f(0.0), (sumEmission / taps) / max(meanAlpha, 1e-6), meanAlpha > 1e-6);
return sample;
}
fn emptyDiskSample() -> DiskSample {
var sample: DiskSample;
sample.color = vec3f(0.0);
sample.alpha = 0.0;
return sample;
}
fn compositeDisk(under: vec3f, sample: DiskSample) -> vec3f {
return sample.color * sample.alpha * DISK_GAIN + under * (1.0 - sample.alpha);
}
@fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
let dimensions = vec2f(textureDimensions(gHit1, 0));
let texel = vec2i(clamp(uv * dimensions, vec2f(0.0), dimensions - vec2f(1.0)));
let aa = textureLoad(gAa, texel, 0).xy;
let aaGeom = textureLoad(gAaGeom, texel, 0);
let baked = decodeGBuffer(
textureLoad(gHit1, texel, 0).xy,
textureLoad(gHit2, texel, 0).xy,
textureLoad(gSky, texel, 0),
textureLoad(gView, texel, 0),
shade.diskOuter,
aa,
aaGeom,
);
let frontAxes = diskFootprintAxes(baked.front);
let backAxes = diskFootprintAxes(baked.back);
let frontFootprint = diskFootprint(frontAxes);
let backFootprint = diskFootprint(backAxes);
let bakedRayDirection = baked.front.rayDirection;
let skyDdx = dpdx(bakedRayDirection);
let skyDdy = dpdy(bakedRayDirection);
let layers = rotateLayers(baked, -shade.sceneYaw);
let g = layers.front;
let skyDdxRotated = rotateY(skyDdx, -shade.sceneYaw);
let skyDdyRotated = rotateY(skyDdy, -shade.sceneYaw);
var background = vec3f(0.0);
if (!g.isBlackHole && g.escaped) {
background = shadeStars(g.rayDirection, stars, shade.time, skyDdxRotated, skyDdyRotated);
}
var backSample = emptyDiskSample();
var frontSample = emptyDiskSample();
if (layers.back.isHit) {
backSample = shadeDisk(layers.back, disk, shade.time, backFootprint, noiseVolume, noiseSampler);
}
if (layers.front.isHit) {
frontSample = shadeFront(layers.front, frontFootprint, frontAxes.x);
frontSample.alpha *= layers.front.coverage;
}
var color = background;
color = compositeDisk(color, backSample);
color = compositeDisk(color, frontSample);
let centerMask = mix(
1.0,
centeredCopyFade(uv.y),
clamp(shade.centerFade, 0.0, 1.0),
);
let heroFade = centerMask;
color *= heroFade;
return vec4f(color, 1.0);
}
registry/new-york/templates/portfolio-v5/components/black-hole/stars.wgsl
// Procedural lensed star field with anisotropic footprint prefiltering.
import { pcg3d, unitFloat } from "@vgpu/wgsl-std/hash";
const STAR_INTENSITY: f32 = 1.9;
const ANCHOR_CELLS: f32 = 36.0;
const ANCHOR_FILL: f32 = 0.75;
const ANCHOR_RADIUS: f32 = 0.00110;
const ANCHOR_PEAK: f32 = 1.0;
const FIELD_CELLS: f32 = 93.0;
const FIELD_FILL: f32 = 0.75;
const FIELD_RADIUS: f32 = 0.00070;
const FIELD_PEAK: f32 = 0.45;
const DUST_CELLS: f32 = 151.0;
const DUST_FILL: f32 = 0.75;
const DUST_RADIUS: f32 = 0.00040;
const DUST_PEAK: f32 = 0.22;
const COUNT_SLOPE: f32 = 2.0;
const STAR_FLUX_AREA: f32 = 0.5385;
const MAX_PREFILTER_PIXELS: f32 = 4.0;
const STAR_WARM: vec3f = vec3f(1.1741, 0.9745, 0.7397);
const STAR_COOL: vec3f = vec3f(0.8954, 1.0131, 1.1781);
export struct StarLook {
brightness: f32,
density: f32,
contrast: f32,
warmth: f32,
twinkle: f32,
}
fn faceCoords(direction: vec3f) -> vec3f {
let magnitude = abs(direction);
if (magnitude.x >= magnitude.y && magnitude.x >= magnitude.z) {
return vec3f(direction.yz / magnitude.x, select(1.0, 0.0, direction.x > 0.0));
}
if (magnitude.y >= magnitude.z) {
return vec3f(direction.xz / magnitude.y, select(3.0, 2.0, direction.y > 0.0));
}
return vec3f(direction.xy / magnitude.z, select(5.0, 4.0, direction.z > 0.0));
}
fn faceProject(direction: vec3f, axis: i32) -> vec2f {
if (axis == 0) {
return direction.yz / abs(direction.x);
}
if (axis == 1) {
return direction.xz / abs(direction.y);
}
return direction.xy / abs(direction.z);
}
struct SkyFilter {
inverseJacobian: mat2x2f,
pixelsPerFace: f32,
faceMajor: f32,
}
fn skyFilter(direction: vec3f, axis: i32, ddx: vec3f, ddy: vec3f) -> SkyFilter {
let base = faceProject(direction, axis);
let jx = faceProject(direction + ddx, axis) - base;
let jy = faceProject(direction + ddy, axis) - base;
let determinant = jx.x * jy.y - jx.y * jy.x;
let safeDeterminant = select(determinant, 1.0e-24, abs(determinant) < 1.0e-24);
let inverse = mat2x2f(vec2f(jy.y, -jx.y), vec2f(-jy.x, jx.x)) * (1.0 / safeDeterminant);
var prefilter: SkyFilter;
prefilter.inverseJacobian = inverse;
prefilter.pixelsPerFace = 1.0 / sqrt(max(abs(determinant), 1.0e-24));
prefilter.faceMajor = max(length(jx), length(jy));
return prefilter;
}
struct SkyState {
brightness: f32,
rangePower: f32,
meanFlux: f32,
warmth: f32,
twinkle: f32,
time: f32,
fillScale: f32,
radiusScale: f32,
}
fn resolveSky(look: StarLook, face: vec2f, time: f32) -> SkyState {
let range = clamp(look.contrast, 1.0, 512.0);
let rangePower = range * range;
let compression = 1.0 + dot(face, face);
let root = sqrt(compression);
var sky: SkyState;
sky.brightness = max(0.0, look.brightness) * STAR_INTENSITY;
sky.rangePower = rangePower;
sky.meanFlux = COUNT_SLOPE / (range + COUNT_SLOPE - 1.0);
sky.warmth = clamp(look.warmth, 0.0, 1.0);
sky.twinkle = clamp(look.twinkle, 0.0, 1.0);
sky.time = time;
sky.fillScale = max(0.0, look.density) / (compression * root);
sky.radiusScale = sqrt(compression * root);
return sky;
}
struct Species {
cells: f32,
fill: f32,
peak: f32,
faceRadius: f32,
radiusPixels: f32,
gain: f32,
}
fn resolveSpecies(
cells: f32,
fill: f32,
peak: f32,
angularRadius: f32,
sky: SkyState,
prefilter: SkyFilter,
) -> Species {
let faceRadius = angularRadius * sky.radiusScale;
let starPixels = faceRadius * prefilter.pixelsPerFace;
var species: Species;
species.cells = cells;
species.fill = clamp(fill * sky.fillScale, 0.0, 1.0);
species.peak = peak * sky.brightness;
species.faceRadius = faceRadius;
species.radiusPixels = clamp(starPixels, 1.0, MAX_PREFILTER_PIXELS);
species.gain = min(1.0, starPixels * starPixels);
return species;
}
fn starPoint(
cell: vec2f,
grid: vec2f,
faceIndex: i32,
seed: i32,
species: Species,
sky: SkyState,
prefilter: SkyFilter,
) -> vec3f {
let hashed = pcg3d(bitcast<vec3u>(vec3i(vec2i(cell), faceIndex * 131 + seed)));
let presence = unitFloat(hashed.x);
if (presence > species.fill) {
return vec3f(0.0);
}
let jitter = vec2f(unitFloat(hashed.y), unitFloat(hashed.z)) - vec2f(0.5);
let center = cell + vec2f(0.5) + jitter * 0.8;
let offsetPixels = prefilter.inverseJacobian * ((grid - center) / species.cells);
let falloff = 1.0 - smoothstep(0.0, species.radiusPixels, length(offsetPixels));
let uniform01 = presence / max(species.fill, 1.0e-6);
let flux = inverseSqrt(1.0 + uniform01 * (sky.rangePower - 1.0));
let tint = mix(vec3f(1.0), mix(STAR_WARM, STAR_COOL, unitFloat(hashed.y ^ hashed.z)), sky.warmth);
let phase = unitFloat(hashed.y) * 6.2831853;
let shimmer = 1.0 + sky.twinkle * 0.06 * sin(sky.time * (0.35 + unitFloat(hashed.z) * 0.4) + phase);
return tint * (falloff * falloff * species.peak * flux * shimmer * species.gain);
}
fn starSpecies(
face: vec3f,
seed: i32,
species: Species,
sky: SkyState,
prefilter: SkyFilter,
) -> vec3f {
let faceIndex = i32(face.z);
let grid = face.xy * species.cells;
let total = starPoint(floor(grid), grid, faceIndex, seed, species, sky, prefilter);
let extent = species.faceRadius * species.cells;
let mean = species.peak * sky.meanFlux * species.fill * STAR_FLUX_AREA * extent * extent;
let meanTint = mix(vec3f(1.0), 0.5 * (STAR_WARM + STAR_COOL), sky.warmth);
let cellsPerPixel = species.cells * prefilter.faceMajor;
return mix(total, meanTint * mean, smoothstep(1.0, 3.0, cellsPerPixel));
}
export fn shadeStars(direction: vec3f, look: StarLook, time: f32, ddx: vec3f, ddy: vec3f) -> vec3f {
let d = normalize(direction);
let face = faceCoords(d);
let prefilter = skyFilter(d, i32(face.z) / 2, ddx, ddy);
let sky = resolveSky(look, face.xy, time);
return starSpecies(
face, 17,
resolveSpecies(ANCHOR_CELLS, ANCHOR_FILL, ANCHOR_PEAK, ANCHOR_RADIUS, sky, prefilter),
sky, prefilter,
) + starSpecies(
face, 71,
resolveSpecies(FIELD_CELLS, FIELD_FILL, FIELD_PEAK, FIELD_RADIUS, sky, prefilter),
sky, prefilter,
) + starSpecies(
face, 149,
resolveSpecies(DUST_CELLS, DUST_FILL, DUST_PEAK, DUST_RADIUS, sky, prefilter),
sky, prefilter,
);
}
registry/new-york/templates/portfolio-v5/lib/wgsl-env.d.ts
/// <reference types="@webgpu/types" />
/// <reference types="@vgpu/wgsl/wgsl-types" />
registry/new-york/templates/portfolio-v5/next.config.ts
import type { NextConfig } from "next"
type WebpackConfig = {
module?: { rules?: unknown[] }
}
const nextConfig: NextConfig = {
turbopack: {
rules: {
"*.wgsl": {
loaders: ["@vgpu/wgsl/loader-webpack"],
as: "*.js",
},
},
},
webpack(config: WebpackConfig) {
config.module ??= {}
config.module.rules ??= []
config.module.rules.push({
test: /\.wgsl$/,
loader: "@vgpu/wgsl/loader-webpack",
})
return config
},
}
export default nextConfig
registry/new-york/templates/portfolio-v5/AGENTS.md
# Personalizing this portfolio
This site ships with filler content for a fictional fullstack developer
("Jon Doe"). It's a demo persona, not a template you fill in
field-by-field — your job is to fully re-skin the site as the real person
who owns this project.
This is a single-page site: one screen with a row of social links, a
short about section, and a list of projects, over a WebGPU black hole.
There are no other routes. All of that copy lives in `app/page.tsx` —
same pattern as portfolio-v1.
**Trigger:** the user attaches their resume (a PDF) directly to you and
asks you to personalize the site — e.g. by mentioning `@AGENTS.md`. When
that happens, follow the steps below.
The resume is personal data and has no reason to live in this repo. Work
from the attachment. Do **not** copy it into the project directory at any
point — not to read it, not to run the extraction in Step 1, not
"just temporarily while I work." As a safety net in case someone saves it
here by hand, add a line to `.gitignore` before you start (skip if it's
already listed):
```
grep -qxF 'resume.pdf' .gitignore || echo 'resume.pdf' >> .gitignore
```
## Step 1 — Read the resume
Read the attached resume. Pull out:
- Full name
- Real profession / field / title (e.g. "Full-Stack Developer", "Product
Designer", "Marine Biologist") — this drives the tone of everything below
- A narrative bio: a short first-person about section synthesized from
their summary, experience, and notable work, in a voice that matches
their actual field. Do not reuse the demo's "AI infrastructure /
ship in small pieces" framing with the name swapped in — the whole
voice changes.
- Notable projects, portfolio pieces, or work worth listing under Projects
- A real repo or live URL for each of those projects, if the resume has one
- Social / profile links actually present in the resume (LinkedIn, GitHub,
personal site, X, Instagram, Dribbble, etc.)
### Recovering the real URL behind a link
Reading the PDF only gives you visible text — "GitHub" as a clickable
label doesn't tell you what it links to. Before treating any link as
unresolved, try recovering the actual target from the raw bytes of the
file.
This needs a real filesystem path, and the resume is an attachment, not a
file in the repo. Copy it to a scratch location **outside the project** —
your OS temp directory — run the search there, and delete it when done:
```
# adjust the source to wherever the attachment actually is on disk
cp <attached-resume-path> "${TMPDIR:-/tmp}/resume.pdf"
grep -a -o "/URI[^)]*)" "${TMPDIR:-/tmp}/resume.pdf"
rm "${TMPDIR:-/tmp}/resume.pdf"
```
On Windows use the temp dir (`"$TEMP/resume.pdf"` in Git Bash,
`$env:TEMP` in PowerShell). Never copy it under the project directory,
even for a moment. If you can't get a filesystem path to the attachment at
all, ask the user for the path to the file on their machine and grep it in
place — still don't bring it into the repo.
Most resumes (exported from Google Docs, Word, or a resume builder) store
their hyperlink targets as plain-text `/URI (...)` entries even when the
rest of the PDF is compressed, so this works more often than not. Match
each URL back to the label near it in the resume text — a `github.com/...`
URL is GitHub, `linkedin.com/in/...` is LinkedIn, and so on. Do this for
every link-looking label in the resume, not just the obvious ones — it's
what makes Step 3 actually work instead of falling back to asking every
time.
Only fall back to asking the user for the real link if this comes up empty
and the resume text still shows an unresolved label — some PDFs compress
the annotation objects too, which a plain-text search can't see through.
## Step 2 — About section
Rewrite the three about `<p>` paragraphs in `app/page.tsx`, in the new
voice from Step 1:
- **Paragraph 1** — who they are: name, role, and what they work on or who
for. The original is "I'm Jon, a fullstack developer in Portland. Most
of my work is with early AI infrastructure teams."
- **Paragraph 2** — a concrete stance on how they work (the demo: "I keep
the stack small and ship in small pieces...").
- **Paragraph 3** — a line or two about life outside work that sets up the
projects below (the demo's cooking / running / books map to the demo
projects). If the person's real projects aren't personal side-projects,
this can be more about focus areas or what they're learning — keep it
short and keep it human. A nurse or an accountant doesn't get the "ship
in small pieces" developer framing; match the stance to their field.
Also update `app/layout.tsx` — `metadata.title` (currently "Jon Doe" →
their name) and `metadata.description`.
**Length budget:** the demo about section is three short paragraphs on
purpose. Count the words before you rewrite and stay within about 20%.
Don't add a fourth paragraph. A longer about section isn't more thorough,
it's just a worse read.
The page has no name heading and no job-title line — the about copy is the
only identity prose on the site. Don't add an `<h1>` name; that's a
deliberate design choice for this template.
## Step 3 — Links
Do not shorten or remove the Instagram, X, and GitHub links in
`app/page.tsx` — keep all three labels. This is a fixed set of redirect
slots, not a list you edit down to what the resume happens to mention.
("X" is the platform formerly called Twitter — a resume may still label it
either way.) Same pattern as portfolio-v1.
For each of the three, the `href` rule is the same for every platform,
every time — resolved or not:
- Resume has a real link/handle for the platform (most dev resumes have a
GitHub) → replace that one `href` with the real URL. Keep
`target="_blank" rel="noreferrer"` as-is — it already opens in a new tab.
- Resume doesn't mention the platform (Instagram usually won't; a resume
link labeled "Twitter" is the same platform as X — fill the X slot
with it, don't treat it as a new one), **or you can't recover a real URL
for it** → set `href="#"`. Don't leave the original bare-domain
placeholder (`https://instagram.com`, etc.) — that's a working link to
the wrong page, which is worse than an obvious placeholder. `href="#"`
reads unambiguously as "not filled in yet."
A link's visible text alone ("Portfolio", "GitHub") doesn't tell you the
`href` — use the extraction command from Step 1 to recover it before
concluding it's unresolved. Only after that comes up empty do you treat it
like "not on the resume": don't guess a URL, don't leave a URL that looks
real but isn't. Set it to `href="#"`, and ask the user for the actual link
so you can fill it in for real.
**Never add a "Portfolio" link found on the resume.** This site *is* their
portfolio — that resume link points at wherever their portfolio currently
lives, and once they've personalized this template, this is that site.
Skip it entirely; don't add it to the list, no matter what domain it
resolves to.
**Beyond the fixed three, actively check the resume for these and add each
one you find as a new `<Button variant="link" asChild>` in the same nav** — never remove or replace
any of the original three to make room for them, only append:
- **LinkedIn** — check for this every time. It's the single most common
link on a resume that isn't already one of the three slots. If found, it
goes right after X and before GitHub, so the order reads
Instagram, X, LinkedIn, and GitHub.
- Anything else the resume links to (Dribbble, a Mastodon handle, etc.)
also goes at the end, in the order you found it. This isn't a closed
list — LinkedIn is just the one to never skip checking for.
Exact placement for LinkedIn, so it's unambiguous — this is the only block
in `app/page.tsx` that changes shape:
```jsx
<nav className="pointer-events-auto flex flex-wrap gap-x-4 gap-y-1">
<Button
variant="link"
asChild
className="h-auto p-0 text-base font-normal text-white underline decoration-white/40 underline-offset-4 hover:decoration-white"
>
<a href="#" target="_blank" rel="noreferrer">
Instagram
</a>
</Button>
<Button
variant="link"
asChild
className="h-auto p-0 text-base font-normal text-white underline decoration-white/40 underline-offset-4 hover:decoration-white"
>
<a href="#" target="_blank" rel="noreferrer">
X
</a>
</Button>
<Button
variant="link"
asChild
className="h-auto p-0 text-base font-normal text-white underline decoration-white/40 underline-offset-4 hover:decoration-white"
>
<a
href="https://linkedin.com/in/their-real-handle"
target="_blank"
rel="noreferrer"
>
LinkedIn
</a>
</Button>
<Button
variant="link"
asChild
className="h-auto p-0 text-base font-normal text-white underline decoration-white/40 underline-offset-4 hover:decoration-white"
>
<a
href="https://github.com/their-real-handle"
target="_blank"
rel="noreferrer"
>
GitHub
</a>
</Button>
</nav>
```
This example shows a resume with LinkedIn and GitHub but no Instagram or
X, which is the common case — Instagram and X fall back to `#`, LinkedIn
gets inserted, GitHub gets its real handle. If LinkedIn isn't on the
resume, don't add that block at all; just apply the `#`-or-real rule to
the original three.
## Step 4 — Projects
Replace the `projects` array at the top of `app/page.tsx` with the
person's real projects, portfolio pieces, or notable work from the resume.
- Keep the shape: `name`, `href`, `description`. The description is one
line, roughly the length of the originals — what the project is, not a
pitch.
- Keep four entries. If the resume genuinely doesn't support four distinct
projects, say so and ask before shipping fewer.
- `href`: a real repo or live URL if the resume has one (recover it per
Step 1), otherwise `"#"` — never leave the bare `https://github.com/`
placeholder, which is a working link to the wrong page. Each entry opens
in a new tab already.
## Step 5 — Unslop pass
Before you're done, read `UNSLOP.md` and check the about section and every
project description you wrote against it. AI-generated prose has a
recognizable smell — stacked em dashes, rule-of-three lists, "not just X
but Y," fortune-cookie closing lines, decorative jargon — and short
portfolio copy is exactly where it shows. Rewrite what trips those
patterns before finishing.
## Do not touch
- `components/black-hole.tsx`, everything under `components/black-hole/`,
and the `<BlackHole />` usage in `app/layout.tsx`. The background is a
near-verbatim port of vgpu's Optimized Black Hole example; it isn't
derived from the resume. It ships on by default; the owner removes it
themselves if they don't want it.
- `next.config.ts` — it registers the WGSL loader for Turbopack and
webpack. The black hole won't render without it.
- `lib/wgsl-env.d.ts`. The template needs no env vars, so there is no
`.env.example`.
- Theme, CSS, and layout structure — the copy and social `href`s in
`app/page.tsx` and `app/layout.tsx` metadata are the only things that
change.
## When you're done
The resume was never copied into the repo, so there's nothing to clean up
there. If you made a scratch copy in a temp directory for the Step 1
extraction, confirm it's deleted. If the user has a `resume.pdf` sitting in
the project root anyway (a manual drop, or a previous run), point it out
and offer to remove it — `.gitignore` now keeps it from being committed
either way.
registry/new-york/templates/portfolio-v5/UNSLOP.md
# Unslop
A pass to run against everything written during personalization — the
about paragraphs and every project `description` in `app/page.tsx`. This
applies whenever `AGENTS.md` has you drafting prose for this site.
Short copy is where AI tells are most obvious, and this template is almost
nothing but short copy. Hold it to a high bar.
## Process
1. Draft the content per `AGENTS.md`.
2. Before you're done, read it back against every pattern below.
3. Rewrite what trips a pattern. Preserve the meaning and the person's real
voice — don't flatten it, just strip the tells.
4. Self-audit once more: "would this specific sentence survive if a human
who does this work for real wrote it?" If not, cut or rewrite it.
## Patterns to detect and fix
### Style
1. **Em dashes.** This is short copy — use none. A single em dash in a
two-sentence bio is the most common generated-bio tell there is. Use a
period or a comma.
- Bad: "Hey — I'm Jon. Fullstack developer — building for AI teams."
- Better: "I'm Jon, a fullstack developer."
2. **Colon as a mid-sentence connector.** "The honest version:" or "The
short story:" add nothing — they're a wind-up before the actual
sentence. Cut the wind-up, state the point.
3. **Manufactured profundity.** A closing line that sounds like a fortune
cookie is a tell, not a flourish.
- Bad: "I build things I'd want to use. That's the whole philosophy."
- Just say what the thing is: "A recipe box for the meals I actually
cook."
4. **Rhetorical setups.** "X reads like Y until you realize Z" and similar
setup-then-reveal openings are a generated pattern. Open with the fact.
5. **Title case, curly quotes, decorative emphasis.** Sentence case,
straight quotes, no bolding proper nouns.
### Language
6. **AI vocabulary.** Additionally, crucial, delve, empower, facilitate,
foster, garner, intricate, landscape (abstract), leverage, pivotal,
robust, seamless, showcase, streamline, tapestry, testament,
transformative, underscore, utilize, vibrant, "at the intersection of."
Replace with the plain word or cut.
7. **Fancy ways to say "is."** "serves as", "stands as", "boasts". Just say
"is" or "has."
8. **"Not just X, but Y."** State the point directly.
9. **Rule of three.** Two triads back to back — "simple, tested, and
maintainable" then "cooking, running, and reading" — is a strong tell.
Use the number of items that's actually true, and vary the sentence
shape so it isn't three identical parallel clauses every time.
10. **Vague attribution.** "People say", "known for" — if there's no
source, cut the claim or make it a fact you can stand behind.
### Length
11. **Padding past the point.** An extra clause restating the previous one,
an implication spelled out that the reader already got. `AGENTS.md`
gives a word-count budget measured against what you're replacing — if
you're over it, cut a sentence, don't tighten the wording of all of
them.
12. **One idea per paragraph.** If the two `details` paragraphs make the
same point from different sides, keep the sharper one and cut the
other, or make the second one do a different job (the demo's second
paragraph is about life outside work, not more work philosophy).
### Voice
13. **Say what happened, not how it felt.** "made the workflow seamless"
names a feeling. "cut the review step from three tools to one" names a
fact. If a project description could sit unchanged in anyone else's
portfolio, it says nothing — make it specific to this project.
14. **Active voice, real actor.** "the score was improved by streaming"
becomes "streaming the response improved the score."
15. **Cut adverbs propping up a weak verb.** "runs significantly faster"
becomes the actual number, or a stronger verb.
16. **Plain word over the fancier synonym.** "utilize" → "use", "leverage"
→ "use", "facilitate" → "help", "numerous" → "many."
17. **Chatbot artifacts.** No "I hope this helps," no apologizing, no
address to a reader who isn't there — this is a bio, not a chat reply.
## Sound test
Read the about section out loud. If it sounds like something written
_about_ the person rather than _by_ them, it's still slopped.