release: v0.9.1 (신규 쇼케이스 3종 정식 출시, CSS 변수 및 경로 무결성 게이트 강화)
This commit is contained in:
parent
e26b00a825
commit
225c1d9f42
75 changed files with 7406 additions and 224 deletions
BIN
apps/site/src/assets/shots/work-cask-archive-2.png
Normal file
BIN
apps/site/src/assets/shots/work-cask-archive-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 560 KiB |
BIN
apps/site/src/assets/shots/work-cask-archive.png
Normal file
BIN
apps/site/src/assets/shots/work-cask-archive.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 438 KiB |
BIN
apps/site/src/assets/shots/work-cheongon-care-2.png
Normal file
BIN
apps/site/src/assets/shots/work-cheongon-care-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 378 KiB |
BIN
apps/site/src/assets/shots/work-cheongon-care.png
Normal file
BIN
apps/site/src/assets/shots/work-cheongon-care.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 449 KiB |
BIN
apps/site/src/assets/shots/work-materia-interior-2.png
Normal file
BIN
apps/site/src/assets/shots/work-materia-interior-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 561 KiB |
BIN
apps/site/src/assets/shots/work-materia-interior.png
Normal file
BIN
apps/site/src/assets/shots/work-materia-interior.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 474 KiB |
64
apps/site/src/components/ShowcaseHeroCanvas.astro
Normal file
64
apps/site/src/components/ShowcaseHeroCanvas.astro
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
const { class: className } = Astro.props;
|
||||
---
|
||||
|
||||
<div class:list={["hero-gl-wrap", className]} aria-hidden="true">
|
||||
<div class="hero-gl-fallback"></div>
|
||||
<canvas id="showcase-gl" class="hero-gl-canvas" style="opacity: 0;"></canvas>
|
||||
<div class="hero-gl-scrim"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { boot } from "../gl/boot.ts";
|
||||
|
||||
const canvas = document.getElementById("showcase-gl") as HTMLCanvasElement | null;
|
||||
if (canvas) {
|
||||
boot(canvas, () => import("../gl/scenes/showcase-hero.ts"));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.hero-gl-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(to bottom, black 70%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 70%, transparent 100%);
|
||||
}
|
||||
|
||||
.hero-gl-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(85% 60% at 50% -10%, color-mix(in oklab, var(--accent) 18%, transparent) 0%, transparent 70%),
|
||||
radial-gradient(60% 50% at 85% 20%, rgba(217, 155, 56, 0.12) 0%, transparent 60%),
|
||||
linear-gradient(180deg, var(--surface-sunken) 0%, var(--surface) 100%);
|
||||
}
|
||||
|
||||
.hero-gl-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: none !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hero-gl-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 50% 30%, rgba(12, 16, 23, 0.35) 0%, rgba(12, 16, 23, 0.8) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-gl-canvas {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -34,6 +34,12 @@ import gaonLms from "../assets/shots/work-gaon-lms.png";
|
|||
import gaonLms2 from "../assets/shots/work-gaon-lms-2.png";
|
||||
import dureEnrollment from "../assets/shots/work-dure-enrollment.png";
|
||||
import dureEnrollment2 from "../assets/shots/work-dure-enrollment-2.png";
|
||||
import cheongonCare from "../assets/shots/work-cheongon-care.png";
|
||||
import cheongonCare2 from "../assets/shots/work-cheongon-care-2.png";
|
||||
import caskArchive from "../assets/shots/work-cask-archive.png";
|
||||
import caskArchive2 from "../assets/shots/work-cask-archive-2.png";
|
||||
import materiaInterior from "../assets/shots/work-materia-interior.png";
|
||||
import materiaInterior2 from "../assets/shots/work-materia-interior-2.png";
|
||||
|
||||
export interface WorkItem {
|
||||
slug: string;
|
||||
|
|
@ -45,6 +51,7 @@ export interface WorkItem {
|
|||
date: string;
|
||||
metrics: string[];
|
||||
href: string;
|
||||
subLinks?: { label: string; href: string }[];
|
||||
shot: ImageMetadata;
|
||||
shotAlt?: ImageMetadata;
|
||||
alt: string;
|
||||
|
|
@ -258,4 +265,60 @@ export const WORK: WorkItem[] = [
|
|||
alt: "두레대학 수강신청 — 과목 원장 목록과 주홍 도장 블록이 놓이는 주간 시간표 격자",
|
||||
size: "md",
|
||||
},
|
||||
|
||||
/* ── 7행: 신규 쇼케이스 3종 (2 + 2 + 2) ── */
|
||||
{
|
||||
slug: "cheongon-care",
|
||||
name: "청온 케어 (淸溫)",
|
||||
kind: "위생 케어 & 관제 콘솔 · 2모드",
|
||||
concept: "임상 수준의 6단계 위생 프로토콜 — 정찰제 견적과 실시간 배차 관제 콘솔",
|
||||
risk: "고객용 견적 신청 폼과 관리자용 관제 대시보드가 단일 웹앱에서 실시간 상태를 공유하는 풀-루프 인터랙션",
|
||||
preset: "ledger-console",
|
||||
date: "2026-08",
|
||||
metrics: ["고객·관리자 2모드", "실시간 정찰제 견적기", "32개 QC 체크리스트", "에메랄드 1색", "하드 게이트 14/14"],
|
||||
href: "/work/cheongon-care/",
|
||||
subLinks: [
|
||||
{ label: "임상 6단계 위생 프로토콜 & ATP 리포트", href: "/work/cheongon-care/protocol/" }
|
||||
],
|
||||
shot: cheongonCare,
|
||||
shotAlt: cheongonCare2,
|
||||
alt: "청온 케어 — 정밀 입주 케어가 완료된 거실 전경과 독일 카처 고온 스팀 위생 장비",
|
||||
size: "sm",
|
||||
},
|
||||
{
|
||||
slug: "cask-archive",
|
||||
name: "캐스크 아카이브 (樽室)",
|
||||
kind: "독립병입자 위스키바 & 도슨트",
|
||||
concept: "시간과 배럴이 빚어낸 단 하나의 원액 — 독립병입자 공식 인증 원장과 1:1 도슨트",
|
||||
risk: "일반 메뉴판 대신 단일 캐스크 넘버와 통관 필증이 기록된 캐스크 원장 및 향미 필터를 첫 화면에 배치",
|
||||
preset: "editorial-archival",
|
||||
date: "2026-08",
|
||||
metrics: ["독립병입자 4종 인증", "실시간 캐스크 원장 검색", "1:1 도슨트 예약", "앰버 골드 1색", "하드 게이트 14/14"],
|
||||
href: "/work/cask-archive/",
|
||||
subLinks: [
|
||||
{ label: "1:1 위스키 도슨트 테이스팅 마스터클래스", href: "/work/cask-archive/docent/" }
|
||||
],
|
||||
shot: caskArchive,
|
||||
shotAlt: caskArchive2,
|
||||
alt: "캐스크 아카이브 — 오크 배럴 상판 위 싱글 캐스크 위스키 보틀과 스코틀랜드 숙성고 전경",
|
||||
size: "sm",
|
||||
},
|
||||
{
|
||||
slug: "materia-interior",
|
||||
name: "마테리아 스튜디오 (空間素材)",
|
||||
kind: "하이엔드 공간 인테리어 건축",
|
||||
concept: "빛과 트래버틴이 빚어내는 침묵 — 물성과 자연광의 궤적을 설계하는 아틀리에",
|
||||
risk: "썸네일 그리드를 버리고 트래버틴·오크·리넨의 실측 물성 스펙과 자연광/야간 조도 앰비언스 실시간 전환을 제공",
|
||||
preset: "editorial",
|
||||
date: "2026-08",
|
||||
metrics: ["자연광·야간 조도 전환", "트래버틴 물성 스펙", "더블 베젤 갤러리", "브론즈 1색", "하드 게이트 14/14"],
|
||||
href: "/work/materia-interior/",
|
||||
subLinks: [
|
||||
{ label: "24개 공간 심층 아카이브 갤러리 (한남 듀플렉스)", href: "/work/materia-interior/projects/hannam-duplex/" }
|
||||
],
|
||||
shot: materiaInterior,
|
||||
shotAlt: materiaInterior2,
|
||||
alt: "마테리아 스튜디오 — 트래버틴 벽체 펜트하우스 거실 전경과 천연 마감재 플랫레이",
|
||||
size: "sm",
|
||||
},
|
||||
];
|
||||
|
|
|
|||
71
apps/site/src/gl/boot.ts
Normal file
71
apps/site/src/gl/boot.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Three.js 지연 로드 부트스트랩
|
||||
* designpaca/references/three.md 코드 A 준수
|
||||
*/
|
||||
export async function boot(
|
||||
canvas: HTMLCanvasElement | null,
|
||||
loader: () => Promise<{ default: (canvas: HTMLCanvasElement, opts: any) => () => void }>
|
||||
) {
|
||||
if (!canvas || !gate()) {
|
||||
canvas?.remove();
|
||||
return null;
|
||||
}
|
||||
|
||||
await inViewport(canvas);
|
||||
|
||||
try {
|
||||
const { default: mount } = await loader();
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
const dispose = mount(canvas, { css });
|
||||
|
||||
const dur = css.getPropertyValue("--dur-normal").trim() || "350ms";
|
||||
const ease = css.getPropertyValue("--ease-out").trim() || "ease";
|
||||
canvas.style.transition = `opacity ${dur} ${ease}`;
|
||||
requestAnimationFrame(() => {
|
||||
canvas.style.opacity = "1";
|
||||
});
|
||||
|
||||
window.addEventListener("pagehide", dispose, { once: true });
|
||||
return dispose;
|
||||
} catch (err) {
|
||||
console.warn("[designpaca:three] WebGL init fallback:", err);
|
||||
canvas.remove();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function gate(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return false;
|
||||
if ((navigator as any).connection?.saveData) return false;
|
||||
if (((navigator as any).deviceMemory ?? 8) < 4 || (navigator.hardwareConcurrency ?? 8) < 4) return false;
|
||||
|
||||
try {
|
||||
const probe = document.createElement("canvas");
|
||||
const gl = probe.getContext("webgl2") || probe.getContext("webgl");
|
||||
if (!gl) return false;
|
||||
gl.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function inViewport(el: HTMLElement): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const io = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
io.disconnect();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" }
|
||||
);
|
||||
io.observe(el);
|
||||
});
|
||||
}
|
||||
166
apps/site/src/gl/scenes/showcase-hero.ts
Normal file
166
apps/site/src/gl/scenes/showcase-hero.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import * as THREE from "three";
|
||||
import { createStage } from "../stage.ts";
|
||||
|
||||
const VERT = /* glsl */ `
|
||||
void main() {
|
||||
gl_Position = vec4(position.xy, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAG = /* glsl */ `
|
||||
precision highp float;
|
||||
uniform float uTime;
|
||||
uniform float uScroll;
|
||||
uniform vec2 uResolution;
|
||||
uniform vec2 uPointer;
|
||||
uniform vec3 uSurface;
|
||||
uniform vec3 uRaised;
|
||||
uniform vec3 uAccent;
|
||||
uniform vec3 uAccent2;
|
||||
|
||||
// Simplex 2D noise
|
||||
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||||
vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||||
vec3 permute(vec3 x) { return mod289(((x*34.0)+1.0)*x); }
|
||||
|
||||
float snoise(vec2 v) {
|
||||
const vec4 C = vec4(0.211324865405187,
|
||||
0.366025403784439,
|
||||
-0.577350269189626,
|
||||
0.024390243902439);
|
||||
vec2 i = floor(v + dot(v, C.yy));
|
||||
vec2 x0 = v - i + dot(i, C.xx);
|
||||
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
|
||||
vec4 x12 = x0.xyxy + C.xxzz;
|
||||
x12.xy -= i1;
|
||||
i = mod289(i);
|
||||
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
|
||||
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
|
||||
m = m*m;
|
||||
m = m*m;
|
||||
vec3 x = 2.0 * fract(p * C.www) - 1.0;
|
||||
vec3 h = abs(x) - 0.5;
|
||||
vec3 ox = floor(x + 0.5);
|
||||
vec3 a0 = x - ox;
|
||||
m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
|
||||
vec3 g;
|
||||
g.x = a0.x * x0.x + h.x * x0.y;
|
||||
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
|
||||
return 130.0 * dot(m, g);
|
||||
}
|
||||
|
||||
float fbm(vec2 p) {
|
||||
float v = 0.0;
|
||||
float a = 0.5;
|
||||
mat2 rot = mat2(cos(0.35), sin(0.35), -sin(0.35), cos(0.35));
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
v += a * snoise(p);
|
||||
p = rot * p * 1.75 + vec2(10.0);
|
||||
a *= 0.5;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Normalise coordinates to center, maintaining aspect ratio
|
||||
vec2 st = (gl_FragCoord.xy * 2.0 - uResolution) / max(uResolution.x, uResolution.y);
|
||||
|
||||
// Very gentle, slow breathing tempo (ambient silk light wave, no storm vortex)
|
||||
float t = uTime * 0.035;
|
||||
|
||||
// Gentle pointer glow displacement
|
||||
vec2 pc = (uPointer - 0.5) * 1.5;
|
||||
float pDist = length(st - pc);
|
||||
vec2 pDrift = normalize(st - pc + 1e-4) * exp(-pDist * 2.8) * 0.06;
|
||||
|
||||
// Broad, calm rolling ambient wave
|
||||
vec2 waveCoords = st * 1.1 + pDrift;
|
||||
float n1 = fbm(waveCoords + vec2(t * 0.1, t * 0.07));
|
||||
float n2 = fbm(waveCoords * 1.3 - vec2(t * 0.05, n1 * 0.35));
|
||||
|
||||
float lightField = fbm(waveCoords + vec2(n2 * 0.5, t * 0.04));
|
||||
|
||||
// Base background colors
|
||||
vec3 col = mix(uSurface, uRaised, clamp(lightField * 0.65 + 0.35, 0.0, 1.0));
|
||||
|
||||
// Subtle, elegant emerald & amber mineral glows in soft ambient pockets
|
||||
float accentGlow = smoothstep(-0.25, 0.65, n1) * 0.28;
|
||||
float goldGlow = smoothstep(-0.15, 0.7, n2) * 0.14;
|
||||
|
||||
col = mix(col, uAccent, accentGlow);
|
||||
col = mix(col, uAccent2, goldGlow);
|
||||
|
||||
// Soft vignette
|
||||
float vignette = smoothstep(1.35, 0.35, length(st));
|
||||
col *= (0.75 + 0.25 * vignette);
|
||||
|
||||
// Grain dither to eliminate banding
|
||||
float grain = (fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) * (1.2 / 255.0);
|
||||
col += grain;
|
||||
|
||||
gl_FragColor = vec4(col, 0.92);
|
||||
}
|
||||
`;
|
||||
|
||||
export default function mount(canvas: HTMLCanvasElement, { css }: { css: CSSStyleDeclaration }) {
|
||||
const stage = createStage(canvas, { dprMax: Math.min(window.devicePixelRatio || 1, 1.5), alpha: true });
|
||||
|
||||
const getColor = (token: string, fallback: string) => {
|
||||
const val = css.getPropertyValue(token).trim();
|
||||
return new THREE.Color(val || fallback);
|
||||
};
|
||||
|
||||
// Fullscreen Triangle
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3));
|
||||
|
||||
const uniforms = {
|
||||
uTime: { value: 0 },
|
||||
uScroll: { value: 0 },
|
||||
uResolution: { value: new THREE.Vector2(1, 1) },
|
||||
uPointer: { value: new THREE.Vector2(0.5, 0.5) },
|
||||
uSurface: { value: getColor("--surface-raised", "#121820") },
|
||||
uRaised: { value: getColor("--surface-sunken", "#080c11") },
|
||||
uAccent: { value: getColor("--accent", "#3ee5b3") },
|
||||
uAccent2: { value: new THREE.Color("#d99b38") }, // Warm gold mineral glow
|
||||
};
|
||||
|
||||
const mesh = new THREE.Mesh(
|
||||
geometry,
|
||||
new THREE.ShaderMaterial({
|
||||
vertexShader: VERT,
|
||||
fragmentShader: FRAG,
|
||||
uniforms,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
transparent: true,
|
||||
})
|
||||
);
|
||||
mesh.frustumCulled = false;
|
||||
stage.scene.add(mesh);
|
||||
|
||||
stage.onResize = (w: number, h: number, dpr: number) => {
|
||||
uniforms.uResolution.value.set(w * dpr, h * dpr);
|
||||
};
|
||||
stage.onResize(stage.size.w, stage.size.h, stage.size.dpr);
|
||||
|
||||
const targetPointer = new THREE.Vector2(0.5, 0.5);
|
||||
const onPointerMove = (e: MouseEvent) => {
|
||||
targetPointer.set(e.clientX / window.innerWidth, 1 - e.clientY / window.innerHeight);
|
||||
};
|
||||
window.addEventListener("pointermove", onPointerMove, { passive: true });
|
||||
|
||||
stage.add((t: number, dt: number) => {
|
||||
uniforms.uTime.value = t;
|
||||
uniforms.uScroll.value = window.scrollY || 0;
|
||||
|
||||
// Smooth pointer lerp
|
||||
uniforms.uPointer.value.lerp(targetPointer, Math.min(1, dt * 2.5));
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
geometry.dispose();
|
||||
(mesh.material as THREE.Material).dispose();
|
||||
};
|
||||
}
|
||||
133
apps/site/src/gl/stage.ts
Normal file
133
apps/site/src/gl/stage.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import * as THREE from "three";
|
||||
|
||||
export interface StageOptions {
|
||||
dprMax?: number;
|
||||
alpha?: boolean;
|
||||
}
|
||||
|
||||
export function createStage(canvas: HTMLCanvasElement, { dprMax = 1.5, alpha = true }: StageOptions = {}) {
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
alpha,
|
||||
antialias: false,
|
||||
stencil: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.toneMapping = THREE.NoToneMapping;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
|
||||
camera.position.z = 5;
|
||||
|
||||
const size = { w: 0, h: 0, dpr: 1 };
|
||||
const updaters = new Set<(t: number, dt: number) => void>();
|
||||
let running = false;
|
||||
let last = 0;
|
||||
const t0 = performance.now();
|
||||
|
||||
const draw = (t: number, dt: number) => {
|
||||
for (const f of updaters) f(t, dt);
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
|
||||
const tick = (now: number) => {
|
||||
const dt = Math.min((now - last) / 1000, 1 / 20);
|
||||
last = now;
|
||||
draw((now - t0) / 1000, dt);
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (!running) {
|
||||
running = true;
|
||||
last = performance.now();
|
||||
renderer.setAnimationLoop(tick);
|
||||
}
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
running = false;
|
||||
renderer.setAnimationLoop(null);
|
||||
};
|
||||
|
||||
function resize() {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const w = Math.max(1, Math.round(r.width));
|
||||
const h = Math.max(1, Math.round(r.height));
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, dprMax);
|
||||
|
||||
if (w === size.w && h === size.h && dpr === size.dpr) return;
|
||||
Object.assign(size, { w, h, dpr });
|
||||
|
||||
renderer.setPixelRatio(dpr);
|
||||
renderer.setSize(w, h, false);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
|
||||
stage.onResize?.(w, h, dpr);
|
||||
if (!running) draw(0, 0);
|
||||
}
|
||||
|
||||
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(resize) : null;
|
||||
ro?.observe(canvas);
|
||||
|
||||
const io =
|
||||
typeof IntersectionObserver !== "undefined"
|
||||
? new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop()), { rootMargin: "10%" })
|
||||
: null;
|
||||
io?.observe(canvas);
|
||||
|
||||
const onVis = () => (document.hidden ? stop() : start());
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
|
||||
const onLost = (e: Event) => {
|
||||
e.preventDefault();
|
||||
stop();
|
||||
};
|
||||
canvas.addEventListener("webglcontextlost", onLost);
|
||||
|
||||
const stage = {
|
||||
renderer,
|
||||
scene,
|
||||
camera,
|
||||
size,
|
||||
start,
|
||||
stop,
|
||||
onResize: null as ((w: number, h: number, dpr: number) => void) | null,
|
||||
renderOnce: () => draw(0, 0),
|
||||
add(f: (t: number, dt: number) => void) {
|
||||
updaters.add(f);
|
||||
return () => updaters.delete(f);
|
||||
},
|
||||
setDprMax(v: number) {
|
||||
dprMax = v;
|
||||
size.dpr = -1;
|
||||
resize();
|
||||
},
|
||||
dispose() {
|
||||
stop();
|
||||
ro?.disconnect();
|
||||
io?.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVis);
|
||||
canvas.removeEventListener("webglcontextlost", onLost);
|
||||
scene.traverse((o: any) => {
|
||||
o.geometry?.dispose();
|
||||
const mats = [o.material].flat().filter(Boolean);
|
||||
for (const m of mats) {
|
||||
for (const u of Object.values(m.uniforms ?? {}) as any[]) {
|
||||
if (u?.value?.isTexture) u.value.dispose();
|
||||
}
|
||||
m.dispose();
|
||||
}
|
||||
});
|
||||
scene.clear();
|
||||
renderer.dispose();
|
||||
renderer.forceContextLoss();
|
||||
updaters.clear();
|
||||
},
|
||||
};
|
||||
|
||||
resize();
|
||||
return stage;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -7,10 +7,21 @@
|
|||
*::before,
|
||||
*::after { box-sizing: border-box; }
|
||||
|
||||
img,
|
||||
picture,
|
||||
video,
|
||||
canvas,
|
||||
svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
background: var(--surface);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue