designpaca 초기 구현 — 스킬 · 설치 CLI · 배포 파이프라인
웹 디자인 파이프라인 스킬과 이를 5개 에이전트에 설치하는 CLI 를 담은 모노레포. 스킬 (packages/skill) - SKILL.md 261줄 + 참조 문서 16개 3,349줄. progressive disclosure 로 본문은 절차와 인덱스만, 지식은 references/ 로 분리 - 0~6단계 파이프라인. 규모에 따라 전체·연장·국소 세 경로로 분기 - 하드 게이트 12개는 grep·카운트로 검증 가능한 것만. 취향 판단은 제외 - 미학 프리셋 5종, AI 슬롭 지문 목록, 한글 조판 규칙, SVG 필터·three.js·인터랙티브 모션·HTML-in-Canvas 실전 지침 설치 CLI (packages/cli, packages/core) - npx designpaca 온보딩 TUI. Claude Code · Codex · Cursor · Windsurf · AGENTS.md - 매니페스트에 설치 시점 해시를 기록해 사용자가 고친 파일은 update 가 건너뛴다 - 타깃별로 본문의 references/ 경로를 실제 설치 위치로 재작성 - AGENTS.md 는 항상 로드되므로 본문 대신 303자 포인터만 주입 - Windsurf 는 12,000자 상한 초과 시 설치를 차단 배포 (build/ci, .forgejo/workflows) - 태그 v* → 검사·테스트·빌드 → npmjs 배포 + Forgejo 레지스트리 미러 → draft 릴리스 → Cloudflare Pages. 재실행 멱등 근거 (research/) - 약 250개 웹 소스 조사 결과와 도그푸딩 검증 2건. 스킬의 모든 수치는 여기서 나온다 테스트 22개 통과 (core 16 · cli 6)
This commit is contained in:
commit
8808c672dc
135 changed files with 38838 additions and 0 deletions
938
research/canvas/_raw/jelly.ts
Normal file
938
research/canvas/_raw/jelly.ts
Normal file
|
|
@ -0,0 +1,938 @@
|
|||
import * as sdf from '@typegpu/sdf';
|
||||
import tgpu, { common, d, std } from 'typegpu';
|
||||
|
||||
import { randf } from '@typegpu/noise';
|
||||
import { Slider } from './slider.ts';
|
||||
import { CameraController } from './camera.ts';
|
||||
import {
|
||||
DirectionalLight,
|
||||
HitInfo,
|
||||
LineInfo,
|
||||
ObjectType,
|
||||
Ray,
|
||||
rayMarchLayout,
|
||||
sampleLayout,
|
||||
SdfBbox,
|
||||
} from './dataTypes.ts';
|
||||
import {
|
||||
beerLambert,
|
||||
createBackgroundTexture,
|
||||
createTextures,
|
||||
fresnelSchlick,
|
||||
intersectBox,
|
||||
} from './utils.ts';
|
||||
import { TAAResolver } from './taa.ts';
|
||||
import {
|
||||
AMBIENT_COLOR,
|
||||
AMBIENT_INTENSITY,
|
||||
AO_BIAS,
|
||||
AO_INTENSITY,
|
||||
AO_RADIUS,
|
||||
AO_STEPS,
|
||||
JELLY_IOR,
|
||||
JELLY_SCATTER_STRENGTH,
|
||||
LINE_HALF_THICK,
|
||||
LINE_RADIUS,
|
||||
MAX_DIST,
|
||||
MAX_STEPS,
|
||||
SPECULAR_INTENSITY,
|
||||
SPECULAR_POWER,
|
||||
SURF_DIST,
|
||||
} from './constants.ts';
|
||||
|
||||
const root = await tgpu.init({
|
||||
device: {
|
||||
optionalFeatures: ['timestamp-query'],
|
||||
},
|
||||
});
|
||||
|
||||
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
|
||||
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
|
||||
const context = root.configureContext({ canvas, alphaMode: 'premultiplied' });
|
||||
|
||||
const NUM_POINTS = 17;
|
||||
|
||||
const slider = new Slider(root, d.vec2f(-1, 0), d.vec2f(0.9, 0), NUM_POINTS, -0.03);
|
||||
const bezierTexture = slider.bezierTexture.createView();
|
||||
const bezierBbox = slider.bbox;
|
||||
|
||||
let qualityScale = 1.0;
|
||||
let [width, height] = [canvas.width * qualityScale, canvas.height * qualityScale];
|
||||
|
||||
let textures = createTextures(root, width, height);
|
||||
let backgroundTexture = createBackgroundTexture(root, width, height);
|
||||
|
||||
const sliderElement = document.getElementById('slider') as HTMLInputElement;
|
||||
const valueElement = document.getElementById('value') as HTMLDivElement;
|
||||
|
||||
const valueRawTexture = root.device.createTexture({
|
||||
size: [width, height, 1],
|
||||
format: 'rgba8unorm',
|
||||
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
|
||||
});
|
||||
const valueTextureView = valueRawTexture.createView();
|
||||
|
||||
// Return a number from 0...100 as a string Zero percent...One hundred percent.
|
||||
function getPercentString(n: number): string {
|
||||
if (n === 100) return "One-hundred %";
|
||||
|
||||
const ones: string[] = [
|
||||
"Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine",
|
||||
"Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen"
|
||||
];
|
||||
|
||||
const tens: string[] = [
|
||||
"", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"
|
||||
];
|
||||
|
||||
// Handle 0 through 19
|
||||
if (n < 20) {
|
||||
return `${ones[n]} %`;
|
||||
}
|
||||
|
||||
// Handle 20 through 99
|
||||
const tensWord: string = tens[Math.floor(n / 10)];
|
||||
const onesWord: string = n % 10 === 0 ? "" : `-${ones[n % 10].toLowerCase()}`;
|
||||
|
||||
return `${tensWord}${onesWord} %`;
|
||||
}
|
||||
|
||||
let targetMouseX = 0.9;
|
||||
let currentMouseX = 0.9;
|
||||
|
||||
sliderElement.addEventListener('input', () => {
|
||||
const t = Number(sliderElement.value) / 100.0;
|
||||
targetMouseX = t * 1.9 - 1.0;
|
||||
valueElement.textContent = getPercentString(Number(sliderElement.value));
|
||||
(canvas as any).requestPaint();
|
||||
});
|
||||
valueElement.textContent = getPercentString(Number(sliderElement.value));
|
||||
|
||||
const filteringSampler = root['~unstable'].createSampler({
|
||||
magFilter: 'linear',
|
||||
minFilter: 'linear',
|
||||
});
|
||||
|
||||
const camera = new CameraController(
|
||||
root,
|
||||
d.vec3f(0, 2.7, 1.9),
|
||||
d.vec3f(0, 0, 0),
|
||||
d.vec3f(0, 1, 0),
|
||||
Math.PI / 4,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
const cameraUniform = camera.cameraUniform;
|
||||
|
||||
const lightUniform = root.createUniform(DirectionalLight, {
|
||||
direction: std.normalize(d.vec3f(0.19, -0.24, 0.75)),
|
||||
color: d.vec3f(1, 1, 1),
|
||||
});
|
||||
|
||||
const jellyColorUniform = root.createUniform(d.vec4f, d.vec4f(1.0, 0.45, 0.075, 1.0));
|
||||
const jellyScatterUniform = root.createUniform(d.f32, JELLY_SCATTER_STRENGTH);
|
||||
const groundColorUniform = root.createUniform(d.vec3f, d.vec3f(1.0));
|
||||
const groundTextColorUniform = root.createUniform(d.vec3f, d.vec3f(0.5));
|
||||
|
||||
const randomUniform = root.createUniform(d.vec2f);
|
||||
const blurEnabledUniform = root.createUniform(d.u32);
|
||||
|
||||
const getRay = (ndc: d.v2f) => {
|
||||
'use gpu';
|
||||
const clipPos = d.vec4f(ndc.x, ndc.y, -1.0, 1.0);
|
||||
|
||||
const invView = cameraUniform.$.viewInv;
|
||||
const invProj = cameraUniform.$.projInv;
|
||||
|
||||
const viewPos = invProj.mul(clipPos);
|
||||
const viewPosNormalized = d.vec4f(viewPos.xyz.div(viewPos.w), 1.0);
|
||||
|
||||
const worldPos = invView.mul(viewPosNormalized);
|
||||
|
||||
const rayOrigin = invView.columns[3].xyz;
|
||||
const rayDir = std.normalize(worldPos.xyz.sub(rayOrigin));
|
||||
|
||||
return Ray({
|
||||
origin: rayOrigin,
|
||||
direction: rayDir,
|
||||
});
|
||||
};
|
||||
|
||||
const getSliderBbox = () => {
|
||||
'use gpu';
|
||||
return SdfBbox({
|
||||
left: d.f32(bezierBbox[3]),
|
||||
right: d.f32(bezierBbox[1]),
|
||||
bottom: d.f32(bezierBbox[2]),
|
||||
top: d.f32(bezierBbox[0]),
|
||||
});
|
||||
};
|
||||
|
||||
const sdInflatedPolyline2D = (p: d.v2f) => {
|
||||
'use gpu';
|
||||
const bbox = getSliderBbox();
|
||||
|
||||
const uv = d.vec2f(
|
||||
(p.x - bbox.left) / (bbox.right - bbox.left),
|
||||
(bbox.top - p.y) / (bbox.top - bbox.bottom),
|
||||
);
|
||||
const clampedUV = std.saturate(uv);
|
||||
|
||||
const sampledColor = std.textureSampleLevel(bezierTexture.$, filteringSampler.$, clampedUV, 0);
|
||||
const segUnsigned = sampledColor.x;
|
||||
const progress = sampledColor.y;
|
||||
const normal = sampledColor.zw;
|
||||
|
||||
return LineInfo({
|
||||
t: progress,
|
||||
distance: segUnsigned,
|
||||
normal: normal,
|
||||
});
|
||||
};
|
||||
|
||||
const cap3D = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
const endCap = slider.endCapUniform.$;
|
||||
const secondLastPoint = d.vec2f(endCap.x, endCap.y);
|
||||
const lastPoint = d.vec2f(endCap.z, endCap.w);
|
||||
|
||||
const angle = std.atan2(lastPoint.y - secondLastPoint.y, lastPoint.x - secondLastPoint.x);
|
||||
const rot = d.mat2x2f(std.cos(angle), -std.sin(angle), std.sin(angle), std.cos(angle));
|
||||
|
||||
let pieP = position.sub(d.vec3f(secondLastPoint, 0));
|
||||
pieP = d.vec3f(rot.mul(pieP.xy), pieP.z);
|
||||
const hmm = sdf.sdPie(pieP.zx, d.vec2f(1, 0), LINE_HALF_THICK);
|
||||
const extrudeEnd = sdf.opExtrudeY(pieP, hmm, 0.001) - LINE_RADIUS;
|
||||
return extrudeEnd;
|
||||
};
|
||||
|
||||
const sliderSdf3D = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
const poly2D = sdInflatedPolyline2D(position.xy);
|
||||
|
||||
let finalDist = d.f32(0.0);
|
||||
if (poly2D.t > 0.94) {
|
||||
finalDist = cap3D(position);
|
||||
} else {
|
||||
const body = sdf.opExtrudeZ(position, poly2D.distance, LINE_HALF_THICK) - LINE_RADIUS;
|
||||
finalDist = body;
|
||||
}
|
||||
|
||||
return LineInfo({
|
||||
t: poly2D.t,
|
||||
distance: finalDist,
|
||||
normal: poly2D.normal,
|
||||
});
|
||||
};
|
||||
|
||||
const GroundParams = {
|
||||
groundThickness: 0.03,
|
||||
groundRoundness: 0.02,
|
||||
};
|
||||
|
||||
const rectangleCutoutDist = (position: d.v2f) => {
|
||||
'use gpu';
|
||||
const groundRoundness = GroundParams.groundRoundness;
|
||||
|
||||
return sdf.sdRoundedBox2d(
|
||||
position,
|
||||
d.vec2f(1 + groundRoundness, 0.2 + groundRoundness),
|
||||
0.2 + groundRoundness,
|
||||
);
|
||||
};
|
||||
|
||||
const getMainSceneDist = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
const groundThickness = GroundParams.groundThickness;
|
||||
const groundRoundness = GroundParams.groundRoundness;
|
||||
|
||||
return sdf.opUnion(
|
||||
sdf.sdPlane(position, d.vec3f(0, 1, 0), 0.06),
|
||||
sdf.opExtrudeY(position, -rectangleCutoutDist(position.xz), groundThickness - groundRoundness) -
|
||||
groundRoundness,
|
||||
);
|
||||
};
|
||||
|
||||
const sliderApproxDist = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
const bbox = getSliderBbox();
|
||||
|
||||
const p = position.xy;
|
||||
if (p.x < bbox.left || p.x > bbox.right || p.y < bbox.bottom || p.y > bbox.top) {
|
||||
return 1e9;
|
||||
}
|
||||
|
||||
const poly2D = sdInflatedPolyline2D(p);
|
||||
const dist3D = sdf.opExtrudeZ(position, poly2D.distance, LINE_HALF_THICK) - LINE_RADIUS;
|
||||
|
||||
return dist3D;
|
||||
};
|
||||
|
||||
const getSceneDist = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
const mainScene = getMainSceneDist(position);
|
||||
const poly3D = sliderSdf3D(position);
|
||||
|
||||
const hitInfo = HitInfo();
|
||||
|
||||
if (poly3D.distance < mainScene) {
|
||||
hitInfo.distance = poly3D.distance;
|
||||
hitInfo.objectType = ObjectType.SLIDER;
|
||||
hitInfo.t = poly3D.t;
|
||||
} else {
|
||||
hitInfo.distance = mainScene;
|
||||
hitInfo.objectType = ObjectType.BACKGROUND;
|
||||
}
|
||||
return hitInfo;
|
||||
};
|
||||
|
||||
const getSceneDistForAO = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
const mainScene = getMainSceneDist(position);
|
||||
const sliderApprox = sliderApproxDist(position);
|
||||
return std.min(mainScene, sliderApprox);
|
||||
};
|
||||
|
||||
const sdfSlot = tgpu.slot<(pos: d.v3f) => number>();
|
||||
|
||||
const getNormalFromSdf = tgpu.fn(
|
||||
[d.vec3f, d.f32],
|
||||
d.vec3f,
|
||||
)((position, epsilon) => {
|
||||
'use gpu';
|
||||
const k = d.vec3f(1, -1, 0);
|
||||
|
||||
const offset1 = k.xyy.mul(epsilon);
|
||||
const offset2 = k.yyx.mul(epsilon);
|
||||
const offset3 = k.yxy.mul(epsilon);
|
||||
const offset4 = k.xxx.mul(epsilon);
|
||||
|
||||
const sample1 = offset1.mul(sdfSlot.$(position.add(offset1)));
|
||||
const sample2 = offset2.mul(sdfSlot.$(position.add(offset2)));
|
||||
const sample3 = offset3.mul(sdfSlot.$(position.add(offset3)));
|
||||
const sample4 = offset4.mul(sdfSlot.$(position.add(offset4)));
|
||||
|
||||
const gradient = sample1.add(sample2).add(sample3).add(sample4);
|
||||
|
||||
return std.normalize(gradient);
|
||||
});
|
||||
|
||||
const getNormalCapSdf = getNormalFromSdf.with(sdfSlot, cap3D);
|
||||
const getNormalMainSdf = getNormalFromSdf.with(sdfSlot, getMainSceneDist);
|
||||
|
||||
const getNormalCap = (pos: d.v3f) => {
|
||||
'use gpu';
|
||||
return getNormalCapSdf(pos, 0.01);
|
||||
};
|
||||
|
||||
const getNormalMain = (position: d.v3f) => {
|
||||
'use gpu';
|
||||
if (std.abs(position.z) > 0.22 || std.abs(position.x) > 1.02) {
|
||||
return d.vec3f(0, 1, 0);
|
||||
}
|
||||
return getNormalMainSdf(position, 0.0001);
|
||||
};
|
||||
|
||||
const getSliderNormal = (position: d.v3f, hitInfo: d.Infer<typeof HitInfo>) => {
|
||||
'use gpu';
|
||||
const poly2D = sdInflatedPolyline2D(position.xy);
|
||||
const gradient2D = poly2D.normal;
|
||||
|
||||
const threshold = LINE_HALF_THICK * 0.85;
|
||||
const absZ = std.abs(position.z);
|
||||
const zDistance = std.max(
|
||||
0,
|
||||
((absZ - threshold) * LINE_HALF_THICK) / (LINE_HALF_THICK - threshold),
|
||||
);
|
||||
const edgeDistance = LINE_RADIUS - poly2D.distance;
|
||||
|
||||
const edgeContrib = 0.9;
|
||||
const zContrib = 1.0 - edgeContrib;
|
||||
|
||||
const zDirection = std.sign(position.z);
|
||||
const zAxisVector = d.vec3f(0, 0, zDirection);
|
||||
|
||||
const edgeBlendDistance = edgeContrib * LINE_RADIUS + zContrib * LINE_HALF_THICK;
|
||||
|
||||
const blendFactor = std.smoothstep(
|
||||
edgeBlendDistance,
|
||||
0.0,
|
||||
zDistance * zContrib + edgeDistance * edgeContrib,
|
||||
);
|
||||
|
||||
const normal2D = d.vec3f(gradient2D.xy, 0);
|
||||
const blendedNormal = std.mix(zAxisVector, normal2D, blendFactor * 0.5 + 0.5);
|
||||
|
||||
let normal = std.normalize(blendedNormal);
|
||||
|
||||
if (hitInfo.t > 0.94) {
|
||||
const ratio = (hitInfo.t - 0.94) / 0.02;
|
||||
const fullNormal = getNormalCap(position);
|
||||
normal = std.normalize(std.mix(normal, fullNormal, ratio));
|
||||
}
|
||||
|
||||
return normal;
|
||||
};
|
||||
|
||||
const getNormal = (position: d.v3f, hitInfo: d.Infer<typeof HitInfo>) => {
|
||||
'use gpu';
|
||||
if (hitInfo.objectType === ObjectType.SLIDER && hitInfo.t < 0.96) {
|
||||
return getSliderNormal(position, hitInfo);
|
||||
}
|
||||
|
||||
return std.select(
|
||||
getNormalCap(position),
|
||||
getNormalMain(position),
|
||||
hitInfo.objectType === ObjectType.BACKGROUND,
|
||||
);
|
||||
};
|
||||
|
||||
const sqLength = (a: d.v3f) => {
|
||||
'use gpu';
|
||||
return std.dot(a, a);
|
||||
};
|
||||
|
||||
const getFakeShadow = (position: d.v3f, lightDir: d.v3f): d.v3f => {
|
||||
'use gpu';
|
||||
const jellyColor = jellyColorUniform.$;
|
||||
const endCapX = slider.endCapUniform.$.x;
|
||||
|
||||
if (position.y < -GroundParams.groundThickness) {
|
||||
// Applying darkening under the ground (the shadow cast by the upper ground layer)
|
||||
const fadeSharpness = 30;
|
||||
const inset = 0.02;
|
||||
const cutout = rectangleCutoutDist(position.xz) + inset;
|
||||
const edgeDarkening = std.saturate(1 - cutout * fadeSharpness);
|
||||
|
||||
// Applying a slight gradient based on the light direction
|
||||
const lightGradient = std.saturate(-position.z * 4 * lightDir.z + 1);
|
||||
|
||||
return d
|
||||
.vec3f(1)
|
||||
.mul(edgeDarkening)
|
||||
.mul(lightGradient * 0.5);
|
||||
} else {
|
||||
const finalUV = d.vec2f(
|
||||
(position.x - position.z * lightDir.x * std.sign(lightDir.z)) * 0.5 + 0.5,
|
||||
1 - (-position.z / lightDir.z) * 0.5 - 0.2,
|
||||
);
|
||||
const data = std.textureSampleLevel(bezierTexture.$, filteringSampler.$, finalUV, 0);
|
||||
|
||||
// Normally it would be just data.y, but there transition is too sudden when the jelly is bunched up.
|
||||
// To mitigate this, we transition into a position-based transition.
|
||||
const jellySaturation = std.mix(0, data.y, std.saturate(position.x * 1.5 + 1.1));
|
||||
const shadowColor = std.mix(d.vec3f(0, 0, 0), jellyColor.rgb, jellySaturation);
|
||||
|
||||
const contrast = 20 * std.saturate(finalUV.y) * (0.8 + endCapX * 0.2);
|
||||
const shadowOffset = -0.3;
|
||||
const featherSharpness = 10;
|
||||
const uvEdgeFeather =
|
||||
std.saturate(finalUV.x * featherSharpness) *
|
||||
std.saturate((1 - finalUV.x) * featherSharpness) *
|
||||
std.saturate((1 - finalUV.y) * featherSharpness) *
|
||||
std.saturate(finalUV.y);
|
||||
const influence = std.saturate((1 - lightDir.y) * 2) * uvEdgeFeather;
|
||||
return std.mix(
|
||||
d.vec3f(1),
|
||||
std.mix(shadowColor, d.vec3f(1), std.saturate(data.x * contrast + shadowOffset)),
|
||||
influence,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateAO = (position: d.v3f, normal: d.v3f) => {
|
||||
'use gpu';
|
||||
let totalOcclusion = d.f32(0.0);
|
||||
let sampleWeight = d.f32(1.0);
|
||||
const stepDistance = AO_RADIUS / AO_STEPS;
|
||||
|
||||
for (let i = 1; i <= AO_STEPS; i++) {
|
||||
const sampleHeight = stepDistance * d.f32(i);
|
||||
const samplePosition = position.add(normal.mul(sampleHeight));
|
||||
const distanceToSurface = getSceneDistForAO(samplePosition) - AO_BIAS;
|
||||
const occlusionContribution = std.max(0.0, sampleHeight - distanceToSurface);
|
||||
totalOcclusion += occlusionContribution * sampleWeight;
|
||||
sampleWeight *= 0.5;
|
||||
if (totalOcclusion > AO_RADIUS / AO_INTENSITY) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const rawAO = 1.0 - (AO_INTENSITY * totalOcclusion) / AO_RADIUS;
|
||||
return std.saturate(rawAO);
|
||||
};
|
||||
|
||||
const calculateLighting = (hitPosition: d.v3f, normal: d.v3f, rayOrigin: d.v3f) => {
|
||||
'use gpu';
|
||||
const lightDir = std.neg(lightUniform.$.direction);
|
||||
|
||||
const fakeShadow = getFakeShadow(hitPosition, lightDir);
|
||||
const diffuse = std.max(std.dot(normal, lightDir), 0.0);
|
||||
|
||||
const viewDir = std.normalize(rayOrigin.sub(hitPosition));
|
||||
const reflectDir = std.reflect(std.neg(lightDir), normal);
|
||||
const specularFactor = std.max(std.dot(viewDir, reflectDir), 0) ** SPECULAR_POWER;
|
||||
const specular = lightUniform.$.color.mul(specularFactor * SPECULAR_INTENSITY);
|
||||
|
||||
const baseColor = d.vec3f(0.9);
|
||||
|
||||
const directionalLight = baseColor.mul(lightUniform.$.color).mul(diffuse).mul(fakeShadow);
|
||||
const ambientLight = baseColor.mul(AMBIENT_COLOR).mul(AMBIENT_INTENSITY);
|
||||
|
||||
const finalSpecular = specular.mul(fakeShadow);
|
||||
|
||||
return std.saturate(directionalLight.add(ambientLight).add(finalSpecular));
|
||||
};
|
||||
|
||||
const applyAO = (litColor: d.v3f, hitPosition: d.v3f, normal: d.v3f) => {
|
||||
'use gpu';
|
||||
const ao = calculateAO(hitPosition, normal);
|
||||
const finalColor = litColor.mul(ao);
|
||||
return d.vec4f(finalColor, 1.0);
|
||||
};
|
||||
|
||||
const rayMarchNoJelly = (rayOrigin: d.v3f, rayDirection: d.v3f) => {
|
||||
'use gpu';
|
||||
let distanceFromOrigin = d.f32();
|
||||
let hit = d.f32();
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const p = rayOrigin.add(rayDirection.mul(distanceFromOrigin));
|
||||
hit = getMainSceneDist(p);
|
||||
distanceFromOrigin += hit;
|
||||
if (distanceFromOrigin > MAX_DIST || hit < SURF_DIST * 10) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (distanceFromOrigin < MAX_DIST) {
|
||||
return renderBackground(
|
||||
rayOrigin,
|
||||
rayDirection,
|
||||
distanceFromOrigin,
|
||||
std.select(d.f32(), 0.87, blurEnabledUniform.$ === 1),
|
||||
).rgb;
|
||||
}
|
||||
return d.vec3f();
|
||||
};
|
||||
|
||||
const renderPercentageOnGround = (hitPosition: d.v3f, center: d.v3f) => {
|
||||
'use gpu';
|
||||
|
||||
const textWidth = 1.9;
|
||||
const textHeight = 0.33;
|
||||
|
||||
if (
|
||||
std.abs(hitPosition.x - center.x) > textWidth * 0.5 ||
|
||||
std.abs(hitPosition.z - center.z) > textHeight * 0.5
|
||||
) {
|
||||
return d.vec4f();
|
||||
}
|
||||
|
||||
const localX = hitPosition.x - center.x;
|
||||
const localZ = hitPosition.z - center.z;
|
||||
|
||||
const uvX = (localX + textWidth * 0.5) / textWidth;
|
||||
const uvZ = (localZ + textHeight * 0.5) / textHeight;
|
||||
|
||||
if (uvX < 0.0 || uvX > 1.0 || uvZ < 0.0 || uvZ > 1.0) {
|
||||
return d.vec4f();
|
||||
}
|
||||
|
||||
return std.textureSampleLevel(
|
||||
rayMarchLayout.$.valueTexture,
|
||||
filteringSampler.$,
|
||||
d.vec2f(uvX, uvZ),
|
||||
0,
|
||||
);
|
||||
};
|
||||
|
||||
const renderBackground = (
|
||||
rayOrigin: d.v3f,
|
||||
rayDirection: d.v3f,
|
||||
backgroundHitDist: number,
|
||||
offset: number,
|
||||
) => {
|
||||
'use gpu';
|
||||
const hitPosition = rayOrigin.add(rayDirection.mul(backgroundHitDist));
|
||||
|
||||
const percentageSample = renderPercentageOnGround(
|
||||
hitPosition,
|
||||
d.vec3f(0, 0, 0),
|
||||
);
|
||||
|
||||
let highlights = d.f32();
|
||||
|
||||
const highlightWidth = d.f32(1);
|
||||
const highlightHeight = 0.2;
|
||||
let offsetX = d.f32();
|
||||
let offsetZ = d.f32(0.05);
|
||||
|
||||
const lightDir = lightUniform.$.direction;
|
||||
const causticScale = 0.2;
|
||||
offsetX -= lightDir.x * causticScale;
|
||||
offsetZ += lightDir.z * causticScale;
|
||||
|
||||
const endCapX = slider.endCapUniform.$.x;
|
||||
const sliderStretch = (endCapX + 1) * 0.5;
|
||||
|
||||
if (
|
||||
std.abs(hitPosition.x + offsetX) < highlightWidth &&
|
||||
std.abs(hitPosition.z + offsetZ) < highlightHeight
|
||||
) {
|
||||
const uvX_orig = ((hitPosition.x + offsetX + highlightWidth * 2) / highlightWidth) * 0.5;
|
||||
const uvZ_orig = ((hitPosition.z + offsetZ + highlightHeight * 2) / highlightHeight) * 0.5;
|
||||
|
||||
const centeredUV = d.vec2f(uvX_orig - 0.5, uvZ_orig - 0.5);
|
||||
const finalUV = d.vec2f(centeredUV.x, 1 - (std.abs(centeredUV.y - 0.5) * 2) ** 2 * 0.3);
|
||||
|
||||
const density = std.max(
|
||||
0,
|
||||
(std.textureSampleLevel(bezierTexture.$, filteringSampler.$, finalUV, 0).x - 0.25) * 8,
|
||||
);
|
||||
|
||||
const fadeX = std.smoothstep(0, -0.2, hitPosition.x - endCapX);
|
||||
const fadeZ = 1 - (std.abs(centeredUV.y - 0.5) * 2) ** 3;
|
||||
const fadeStretch = std.saturate(1 - sliderStretch);
|
||||
const edgeFade = std.saturate(fadeX) * std.saturate(fadeZ) * fadeStretch;
|
||||
|
||||
highlights = (density ** 3 * edgeFade * 3 * (1 + lightDir.z)) / 1.5;
|
||||
}
|
||||
|
||||
const originYBound = std.saturate(rayOrigin.y + 0.01);
|
||||
const posOffset = hitPosition.add(
|
||||
d.vec3f(0, 1, 0).mul(offset * (originYBound / (1.0 + originYBound)) * (1 + randf.sample() / 2)),
|
||||
);
|
||||
const newNormal = getNormalMain(posOffset);
|
||||
|
||||
// Calculate fake bounce lighting
|
||||
const jellyColor = jellyColorUniform.$;
|
||||
const sqDist = sqLength(hitPosition.sub(d.vec3f(endCapX, 0, 0)));
|
||||
const bounceLight = jellyColor.rgb.mul((1 / (sqDist * 15 + 1)) * 0.4);
|
||||
const sideBounceLight = jellyColor.rgb
|
||||
.mul((1 / (sqDist * 40 + 1)) * 0.3)
|
||||
.mul(std.abs(newNormal.z));
|
||||
|
||||
const litColor = calculateLighting(posOffset, newNormal, rayOrigin);
|
||||
const backgroundColor = applyAO(groundColorUniform.$.mul(litColor), posOffset, newNormal)
|
||||
.add(d.vec4f(bounceLight, 0))
|
||||
.add(d.vec4f(sideBounceLight, 0));
|
||||
|
||||
const textColor = groundTextColorUniform.$;
|
||||
|
||||
return d.vec4f(
|
||||
std.mix(backgroundColor.rgb, textColor, percentageSample.x).mul(1.0 + highlights),
|
||||
1.0,
|
||||
);
|
||||
};
|
||||
|
||||
const rayMarch = (rayOrigin: d.v3f, rayDirection: d.v3f, _uv: d.v2f) => {
|
||||
'use gpu';
|
||||
let totalSteps = d.u32();
|
||||
|
||||
let backgroundDist = d.f32();
|
||||
for (let i = 0; i < MAX_STEPS; i++) {
|
||||
const p = rayOrigin.add(rayDirection.mul(backgroundDist));
|
||||
const hit = getMainSceneDist(p);
|
||||
backgroundDist += hit;
|
||||
if (hit < SURF_DIST) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const background = renderBackground(rayOrigin, rayDirection, backgroundDist, d.f32());
|
||||
|
||||
const bbox = getSliderBbox();
|
||||
const zDepth = d.f32(0.25);
|
||||
|
||||
const sliderMin = d.vec3f(bbox.left, bbox.bottom, -zDepth);
|
||||
const sliderMax = d.vec3f(bbox.right, bbox.top, zDepth);
|
||||
|
||||
const intersection = intersectBox(rayOrigin, rayDirection, sliderMin, sliderMax);
|
||||
|
||||
if (!intersection.hit) {
|
||||
return background;
|
||||
}
|
||||
|
||||
let distanceFromOrigin = std.max(d.f32(0.0), intersection.tMin);
|
||||
|
||||
for (let i = 0; i < MAX_STEPS; i++) {
|
||||
if (totalSteps >= MAX_STEPS) {
|
||||
break;
|
||||
}
|
||||
|
||||
const currentPosition = rayOrigin.add(rayDirection.mul(distanceFromOrigin));
|
||||
|
||||
const hitInfo = getSceneDist(currentPosition);
|
||||
distanceFromOrigin += hitInfo.distance;
|
||||
totalSteps++;
|
||||
|
||||
if (hitInfo.distance < SURF_DIST) {
|
||||
const hitPosition = rayOrigin.add(rayDirection.mul(distanceFromOrigin));
|
||||
|
||||
if (!(hitInfo.objectType === ObjectType.SLIDER)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const N = getNormal(hitPosition, hitInfo);
|
||||
const I = rayDirection;
|
||||
const cosi = std.min(1.0, std.max(0.0, std.dot(std.neg(I), N)));
|
||||
const F = fresnelSchlick(cosi, d.f32(1.0), d.f32(JELLY_IOR));
|
||||
|
||||
const reflection = std.saturate(d.vec3f(hitPosition.y + 0.2));
|
||||
|
||||
const eta = 1.0 / JELLY_IOR;
|
||||
const k = 1.0 - eta * eta * (1.0 - cosi * cosi);
|
||||
let refractedColor = d.vec3f();
|
||||
if (k > 0.0) {
|
||||
const refrDir = std.normalize(std.add(I.mul(eta), N.mul(eta * cosi - std.sqrt(k))));
|
||||
const p = hitPosition.add(refrDir.mul(SURF_DIST * 2.0));
|
||||
const exitPos = p.add(refrDir.mul(SURF_DIST * 2.0));
|
||||
|
||||
const env = rayMarchNoJelly(exitPos, refrDir);
|
||||
const progress = hitInfo.t;
|
||||
const jellyColor = jellyColorUniform.$;
|
||||
|
||||
const scatterTint = jellyColor.rgb.mul(1.5);
|
||||
const density = d.f32(20.0);
|
||||
const absorb = d.vec3f(1.0).sub(jellyColor.rgb).mul(density);
|
||||
|
||||
const T = beerLambert(absorb.mul(progress ** 2), 0.08);
|
||||
|
||||
const lightDir = std.neg(lightUniform.$.direction);
|
||||
|
||||
const forward = std.max(0.0, std.dot(lightDir, refrDir));
|
||||
const scatter = scatterTint.mul(jellyScatterUniform.$ * forward * progress ** 3);
|
||||
refractedColor = env.mul(T).add(scatter);
|
||||
}
|
||||
|
||||
const jelly = std.add(reflection.mul(F), refractedColor.mul(1 - F));
|
||||
|
||||
const finalJelly = std.mix(background.rgb, jelly, jellyColorUniform.$.w);
|
||||
|
||||
return d.vec4f(finalJelly, 1.0);
|
||||
}
|
||||
|
||||
if (distanceFromOrigin > backgroundDist) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return background;
|
||||
};
|
||||
|
||||
const raymarchFn = tgpu.fragmentFn({
|
||||
in: { uv: d.vec2f },
|
||||
out: d.vec4f,
|
||||
})(({ uv }) => {
|
||||
randf.seed2(randomUniform.$.mul(uv));
|
||||
|
||||
const ndc = d.vec2f(uv.x * 2 - 1, -(uv.y * 2 - 1));
|
||||
const ray = getRay(ndc);
|
||||
|
||||
const color = rayMarch(ray.origin, ray.direction, uv);
|
||||
return d.vec4f(std.tanh(color.rgb.mul(1.3)), 1);
|
||||
});
|
||||
|
||||
const fragmentMain = tgpu.fragmentFn({
|
||||
in: { uv: d.vec2f },
|
||||
out: d.vec4f,
|
||||
})((input) => {
|
||||
return std.textureSample(sampleLayout.$.currentTexture, filteringSampler.$, input.uv);
|
||||
});
|
||||
|
||||
const rayMarchPipeline = root.createRenderPipeline({
|
||||
vertex: common.fullScreenTriangle,
|
||||
fragment: raymarchFn,
|
||||
targets: { format: 'rgba8unorm' },
|
||||
});
|
||||
|
||||
const renderPipeline = root.createRenderPipeline({
|
||||
vertex: common.fullScreenTriangle,
|
||||
fragment: fragmentMain,
|
||||
targets: { format: presentationFormat },
|
||||
});
|
||||
|
||||
let lastTimeStamp = performance.now();
|
||||
let frameCount = 0;
|
||||
const taaResolver = new TAAResolver(root, width, height);
|
||||
|
||||
function createBindGroups() {
|
||||
return {
|
||||
rayMarch: root.createBindGroup(rayMarchLayout, {
|
||||
backgroundTexture: backgroundTexture.sampled,
|
||||
valueTexture: valueTextureView,
|
||||
}),
|
||||
render: [0, 1].map((frame) =>
|
||||
root.createBindGroup(sampleLayout, {
|
||||
currentTexture: taaResolver.getResolvedTexture(frame),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
(canvas as any).onpaint = () => {
|
||||
const sourceDict = { source: valueElement };
|
||||
const destDict = {
|
||||
destination: { texture: valueRawTexture },
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
try {
|
||||
(root.device.queue as any).copyElementImageToTexture(sourceDict, destDict);
|
||||
} catch (e) {
|
||||
// The copyElementImageToTexture API was recently changed to take two maps
|
||||
// (see: https://github.com/WICG/html-in-canvas#idl-changes). This snippet
|
||||
// supports the old syntax temporarily so that the demos do not break.
|
||||
(root.device.queue as any).copyElementImageToTexture(
|
||||
valueElement, width, height, { texture: valueRawTexture });
|
||||
console.log('Note: using old copyElementImageToTexture API');
|
||||
}
|
||||
|
||||
// TODO(pdr): Calculate this correctly using `getElementTransform`. For now,
|
||||
// the transform is just hard-coded.
|
||||
//const view = camera.view;
|
||||
//const proj = camera.proj;
|
||||
//const mvp = m.mat4.mul(proj, view, d.mat4x4f());
|
||||
//const sliderWidth = sliderElement.clientWidth || (canvas.clientWidth * 0.75);
|
||||
const sliderHeight = sliderElement.clientHeight || (canvas.clientHeight * 0.125);
|
||||
let x = (canvas.width / devicePixelRatio) / 8;
|
||||
let y = (canvas.height / devicePixelRatio) / 2 - (sliderHeight / 2);
|
||||
sliderElement.style.transform = `translate(${x}px, ${y}px)`;
|
||||
valueElement.style.transform = `translate(${x}px, ${y}px)`;
|
||||
};
|
||||
(canvas as any).requestPaint();
|
||||
|
||||
let bindGroups = createBindGroups();
|
||||
|
||||
let animationFrameHandle: number;
|
||||
function render(timestamp: number) {
|
||||
frameCount++;
|
||||
camera.jitter();
|
||||
const deltaTime = Math.min((timestamp - lastTimeStamp) * 0.001, 0.1);
|
||||
lastTimeStamp = timestamp;
|
||||
|
||||
randomUniform.write(d.vec2f((Math.random() - 0.5) * 2, (Math.random() - 0.5) * 2));
|
||||
|
||||
const reduce = motionMedia.matches || transparencyMedia.matches;
|
||||
if (reduce) {
|
||||
currentMouseX = targetMouseX;
|
||||
slider.restLen = Math.max(0.001, Math.abs(currentMouseX - slider.anchor[0])) / (slider.n - 1);
|
||||
} else {
|
||||
currentMouseX += (targetMouseX - currentMouseX) * 0.08;
|
||||
slider.restLen = 1.9 / (slider.n - 1);
|
||||
}
|
||||
|
||||
slider.setDragX(currentMouseX);
|
||||
slider.update(deltaTime);
|
||||
|
||||
const currentFrame = frameCount % 2;
|
||||
|
||||
rayMarchPipeline
|
||||
.withColorAttachment({
|
||||
view: textures[currentFrame].sampled,
|
||||
loadOp: 'clear',
|
||||
storeOp: 'store',
|
||||
})
|
||||
.with(bindGroups.rayMarch)
|
||||
.draw(3);
|
||||
|
||||
taaResolver.resolve(textures[currentFrame].sampled, frameCount, currentFrame);
|
||||
|
||||
renderPipeline
|
||||
.withColorAttachment({ view: context })
|
||||
.with(bindGroups.render[currentFrame])
|
||||
.draw(3);
|
||||
|
||||
animationFrameHandle = requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
[width, height] = [canvas.width * qualityScale, canvas.height * qualityScale];
|
||||
camera.updateProjection(Math.PI / 4, width, height);
|
||||
textures = createTextures(root, width, height);
|
||||
backgroundTexture = createBackgroundTexture(root, width, height);
|
||||
taaResolver.resize(width, height);
|
||||
frameCount = 0;
|
||||
|
||||
bindGroups = createBindGroups();
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
handleResize();
|
||||
});
|
||||
resizeObserver.observe(canvas);
|
||||
|
||||
animationFrameHandle = requestAnimationFrame(render);
|
||||
|
||||
|
||||
const hcMedia = window.matchMedia('(forced-colors: active)');
|
||||
const darkMedia = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const contrastMedia = window.matchMedia('(prefers-contrast: more)');
|
||||
|
||||
const motionMedia = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const transparencyMedia = window.matchMedia('(prefers-reduced-transparency: reduce)');
|
||||
|
||||
const updateReducedFeatures = () => {
|
||||
const reduce = motionMedia.matches || transparencyMedia.matches;
|
||||
|
||||
if (reduce) {
|
||||
slider.damping = 1.0;
|
||||
slider.archStrength = 0.0;
|
||||
jellyScatterUniform.write(0.0);
|
||||
} else {
|
||||
slider.damping = 0.01;
|
||||
slider.archStrength = 2.0;
|
||||
jellyScatterUniform.write(JELLY_SCATTER_STRENGTH);
|
||||
}
|
||||
};
|
||||
|
||||
motionMedia.addEventListener('change', updateReducedFeatures);
|
||||
transparencyMedia.addEventListener('change', updateReducedFeatures);
|
||||
updateReducedFeatures();
|
||||
|
||||
const parseColor3 = (colorStr: string): d.Infer<typeof d.vec3f> => {
|
||||
const match = colorStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (match) {
|
||||
return d.vec3f(parseInt(match[1]) / 255, parseInt(match[2]) / 255, parseInt(match[3]) / 255);
|
||||
}
|
||||
return d.vec3f(1.0);
|
||||
};
|
||||
|
||||
const parseColor4 = (colorStr: string): d.Infer<typeof d.vec4f> => {
|
||||
const match = colorStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/);
|
||||
if (match) {
|
||||
const a = match[4] !== undefined ? parseFloat(match[4]) : 1.0;
|
||||
return d.vec4f(parseInt(match[1]) / 255, parseInt(match[2]) / 255, parseInt(match[3]) / 255, a);
|
||||
}
|
||||
return d.vec4f(1.0, 1.0, 1.0, 1.0);
|
||||
};
|
||||
|
||||
const updateColors = () => {
|
||||
const style = getComputedStyle(sliderElement);
|
||||
|
||||
jellyColorUniform.write(parseColor4(style.color));
|
||||
groundColorUniform.write(parseColor3(style.backgroundColor));
|
||||
groundTextColorUniform.write(parseColor3(style.caretColor));
|
||||
(canvas as any).requestPaint?.();
|
||||
};
|
||||
|
||||
sliderElement.addEventListener('focus', updateColors);
|
||||
sliderElement.addEventListener('blur', updateColors);
|
||||
hcMedia.addEventListener('change', updateColors);
|
||||
darkMedia.addEventListener('change', updateColors);
|
||||
contrastMedia.addEventListener('change', updateColors);
|
||||
updateColors();
|
||||
|
||||
|
||||
export function onCleanup() {
|
||||
sliderElement.removeEventListener('focus', updateColors);
|
||||
sliderElement.removeEventListener('blur', updateColors);
|
||||
hcMedia.removeEventListener('change', updateColors);
|
||||
darkMedia.removeEventListener('change', updateColors);
|
||||
contrastMedia.removeEventListener('change', updateColors);
|
||||
motionMedia.removeEventListener('change', updateReducedFeatures);
|
||||
transparencyMedia.removeEventListener('change', updateReducedFeatures);
|
||||
cancelAnimationFrame(animationFrameHandle);
|
||||
resizeObserver.disconnect();
|
||||
root.destroy();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue