히어로와 파이프라인이 같은 씬을 쓰던 것을 나눈다

게이트 씬을 파이프라인으로 옮기면서 히어로에도 남겨뒀더니 같은 그림이
페이지에 두 번 나왔다. 역할이 다르면 씬도 달라야 한다.

  히어로     shader-plane 흐름 필드   노이즈를 두 번 접어 빛의 결을 만든다
                                      공간을 만드는 재질. 아무것도 반응 안 한다
  파이프라인 gate-scene 유리판 여섯   통과하면 그 단계가 켜진다. 주인공
  재료       shader-plane 스테이지    필터 타일이 앉는 무대

히어로에는 무거운 씬을 임포트하지 않는다 — three.md §0 대로 셰이더 플레인 하나다.
lib/hero-scene.ts -> lib/gate-scene.ts 로 리네임했다. 이름이 용도와 다르면
다음 사람이 히어로 것인 줄 알고 고친다.

대비 실측: 캔버스 배경 최악 8.46:1(가장 밝은 중앙 여백), h1 자리 14.83:1.
readPixels 로 재려다 전부 0 이 나왔다 — preserveDrawingBuffer: false 면
버퍼가 비어 있다. 스크린샷 픽셀로 쟀고, 글자 없는 영역만 골랐다.

스킬 three.md
  씬을 두 자리에 재사용하지 마라. 역할(주인공/재질/무대)로 나눠라
  파일 이름을 용도와 맞춰라
  WebGL 위 대비는 readPixels 가 아니라 스크린샷 픽셀로 잰다.
  0 을 검정으로 착각하면 대비가 통과한 것처럼 보인다
This commit is contained in:
Yun Chan 2026-08-20 16:11:53 +09:00
parent af3a08397e
commit 91bb4ae9dc
5 changed files with 124 additions and 29 deletions

View file

@ -246,6 +246,27 @@ WebGL 게이트: `prefers-reduced-motion` 이면 **아예 시작하지 않는다
| 사례 | `drift` 0.4 | 두 결과물이 흐르는 자리라 배경도 같이 흐른다 | | 사례 | `drift` 0.4 | 두 결과물이 흐르는 자리라 배경도 같이 흐른다 |
| 조판 | `drift` 0.25 | **종이는 손에 닿는 거리다.** 원경으로 두면 거짓말이 된다 | | 조판 | `drift` 0.25 | **종이는 손에 닿는 거리다.** 원경으로 두면 거짓말이 된다 |
### three.js 씬 셋은 서로 다른 것이어야 한다
게이트 씬을 파이프라인으로 옮기면서 히어로에도 그대로 남겨뒀더니
**같은 그림이 페이지에 두 번** 나왔다. 히어로를 다른 것으로 바꿨다.
| 자리 | 씬 | 무엇 |
|---|---|---|
| 히어로 | `shader-plane` 흐름 필드 | 노이즈를 두 번 접어 빛이 유리를 지난 결을 만든다. 공간을 만드는 **재질** |
| 파이프라인 | `gate-scene` 유리판 여섯 장 | 스크롤하면 통과하고 그 단계가 켜진다. 의미를 가진 **주인공** |
| 재료 | `shader-plane` 스테이지 | SVG 필터 타일이 앉는 무대 |
히어로에는 무거운 씬을 임포트하지 않는다 — `three.md` §0 이 권하는 대로
**셰이더 플레인 하나**다. 파일 이름도 `hero-scene.ts``gate-scene.ts` 로 고쳤다.
이름이 실제 용도와 다르면 다음 사람이 히어로 것인 줄 알고 고친다.
캔버스 배경 위 본문 대비 실측(스크린샷 픽셀): 최악 **8.46:1**(가장 밝은 중앙 여백),
h1 자리 14.83:1.
> `readPixels` 로 재려다 전부 0 이 나왔다. `preserveDrawingBuffer: false` 면 버퍼가
> 비워져 있다. **WebGL 위 대비는 스크린샷 픽셀로 재라.**
### 게이트 씬을 파이프라인으로 옮겼다 ### 게이트 씬을 파이프라인으로 옮겼다
히어로에서 뺐던 유리판 여섯 장을 여기로 되살렸다. **같은 씬인데 자막이 필요 없어졌다.** 히어로에서 뺐던 유리판 여섯 장을 여기로 되살렸다. **같은 씬인데 자막이 필요 없어졌다.**

View file

@ -162,12 +162,70 @@ const { c, ui } = Astro.props;
<script> <script>
// 유리판이 느리게 흐르며 공간을 만든다. 배경이므로 스스로 설명하지 않는다. /*
// 굴절은 렌더 타겟 한 장으로 만든다 — transmission 재질보다 훨씬 싸다. 히어로 배경은 셰이더 플레인 하나다. 무거운 씬을 임포트하지 않는다 —
import { mountHeroScene } from "../lib/hero-scene"; references/three.md §0 이 권하는 그 구조다.
한때 여기에도 게이트 씬(유리판 여섯 장)을 깔았는데, 그러면 같은 그림이
파이프라인 섹션과 페이지에 두 번 나온다. 게이트 은유는 옆에 여섯 단계가
적힌 그 섹션의 것이고, 여기는 공간을 만드는 재질이면 된다.
*/
import { mountShaderPlane } from "../lib/shader-plane";
const canvas = document.getElementById("hero-canvas") as HTMLCanvasElement | null; const canvas = document.getElementById("hero-canvas") as HTMLCanvasElement | null;
if (canvas) { if (canvas) {
mountHeroScene({ canvas, accentVar: "--accent", glowVar: "--glow-2" }); mountShaderPlane({
canvas,
pointer: true,
speed: 0.5,
colors: { uA: "--accent", uB: "--glow-2" },
fragment: `
precision mediump float;
varying vec2 vUv;
uniform float uTime; uniform float uAspect;
uniform vec2 uPointer;
uniform vec3 uA; uniform vec3 uB;
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x),
mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);
}
float fbm(vec2 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 5; i++) { v += a * noise(p); p *= 2.03; a *= 0.5; }
return v;
}
void main() {
vec2 p = vUv; p.x *= uAspect;
float t = uTime * 0.06;
vec2 m = uPointer; m.x *= uAspect;
// 흐름 필드. 두 번 접어서 빛이 유리를 지난 것처럼 늘어지게 만든다.
vec2 q = vec2(fbm(p * 1.5 + t), fbm(p * 1.5 - t * 0.8 + 4.2));
vec2 r = vec2(fbm(p * 2.1 + q * 1.6 + t * 0.5),
fbm(p * 2.1 + q * 1.6 - t * 0.4 + 1.7));
float f = fbm(p * 1.2 + r * 1.9);
// 광원 둘. 포인터를 아주 조금만 따라간다.
vec2 c1 = mix(vec2(0.30 * uAspect, 0.58), m, 0.14);
vec2 c2 = vec2(0.80 * uAspect, 0.34);
float a = smoothstep(1.00, 0.06, distance(p, c1) + f * 0.42);
float b = smoothstep(0.92, 0.05, distance(p, c2) + r.y * 0.38);
// 결이 지나간 자리에 얇은 선이 선다. 유리 가장자리의 굴절 대신이다.
float streak = smoothstep(0.48, 0.52, f) - smoothstep(0.52, 0.58, f);
vec3 col = uA * a + uB * b * 0.9 + (uA + uB) * 0.5 * streak * 0.16;
float alpha = clamp(a * 1.05 + b * 0.95 + streak * 0.2, 0.0, 1.0) * 0.8;
gl_FragColor = vec4(col, alpha);
}
`,
});
} }
</script> </script>

View file

@ -222,7 +222,7 @@ const { c } = Astro.props;
<script> <script>
// 게이트를 통과할 때 해당 단계를 켠다. 값이 바뀔 때만 DOM 을 쓴다. // 게이트를 통과할 때 해당 단계를 켠다. 값이 바뀔 때만 DOM 을 쓴다.
import { mountHeroScene } from "../lib/hero-scene"; import { mountGateScene } from "../lib/gate-scene";
const canvas = document.getElementById("pipeline-canvas") as HTMLCanvasElement | null; const canvas = document.getElementById("pipeline-canvas") as HTMLCanvasElement | null;
const section = document.getElementById("pipeline"); const section = document.getElementById("pipeline");
@ -230,7 +230,7 @@ const { c } = Astro.props;
if (canvas && section && stops.length) { if (canvas && section && stops.length) {
let last = -1; let last = -1;
mountHeroScene({ mountGateScene({
canvas, canvas,
accentVar: "--accent", accentVar: "--accent",
glowVar: "--glow-2", glowVar: "--glow-2",

View file

@ -1,33 +1,20 @@
/** /**
* 3D . * , .
* *
* . . * . ,
* , . * .
* .
* `shader-plane.ts` .
* *
* . ("순서를 바꾸지 마라" * references/three.md .
* ), .
* . Pipeline .
*
* (research/three/08-proposal.md):
* . GLB . .
*
* :
* ,
* UV . MeshPhysicalMaterial transmission
* .
* references/three.md .
* .
*
* :
* - three . * - three .
* - DPR 1.5 * - DPR 1.5.
* - * -
* -
* - WebGL . * - WebGL .
* CSS * CSS
*/ */
export interface HeroSceneOptions { export interface GateSceneOptions {
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
/** CSS 커스텀 프로퍼티 이름 */ /** CSS 커스텀 프로퍼티 이름 */
accentVar: string; accentVar: string;
@ -61,7 +48,7 @@ const NOISE = `
} }
`; `;
export function mountHeroScene(opts: HeroSceneOptions): void { export function mountGateScene(opts: GateSceneOptions): void {
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return; if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const { canvas } = opts; const { canvas } = opts;
@ -76,7 +63,7 @@ export function mountHeroScene(opts: HeroSceneOptions): void {
io.observe(canvas); io.observe(canvas);
} }
async function boot({ canvas, accentVar, glowVar, onGate, progressFrom }: HeroSceneOptions): Promise<void> { async function boot({ canvas, accentVar, glowVar, onGate, progressFrom }: GateSceneOptions): Promise<void> {
let THREE: typeof import("three"); let THREE: typeof import("three");
try { try {
THREE = await import("three"); THREE = await import("three");

View file

@ -59,6 +59,35 @@ h1 이 36px 이어도 히어로가 압도적이다.
캔버스 위에 글자를 올려야 한다면 **하나만** 올려라. 그리고 거기엔 바탕을 깔아라. 캔버스 위에 글자를 올려야 한다면 **하나만** 올려라. 그리고 거기엔 바탕을 깔아라.
### 씬을 두 자리에 재사용하지 마라
의미를 가진 씬을 만들었다면 그 자리는 하나다. 배경이 허전하다고 같은 씬을 다른 섹션에
또 깔면 **같은 그림이 두 번** 나오고, 첫 번째 자리의 의미까지 희석된다.
한 페이지에 씬이 여럿이면 **역할을 나눠라.**
| 역할 | 구현 | 판단 기준 |
|---|---|---|
| 주인공 | 씬(메시·카메라·이동) | 옆의 콘텐츠가 반응한다 |
| 재질 | 셰이더 플레인 하나 | 공간만 만든다. 아무것도 반응하지 않는다 |
| 무대 | 셰이더 플레인 하나 | 다른 요소가 그 위에 앉는다 |
**파일 이름을 용도와 맞춰라.** `hero-scene.ts` 를 파이프라인에서 쓰기 시작한 순간
다음 사람은 그것을 히어로 것인 줄 알고 고친다. `gate-scene.ts` 로 바꿔라.
### WebGL 위의 대비는 `readPixels` 로 못 잰다
`preserveDrawingBuffer: false`(기본값)면 프레임을 그린 뒤 버퍼가 비워진다.
`gl.readPixels` 는 **전부 0 을 돌려주고, 그 0 을 검정으로 착각하면 대비가 통과한 것처럼 보인다.**
**스크린샷을 찍어 그 픽셀을 재라.** 그리고 글자가 없는 영역만 골라라 —
글자를 포함해 재면 최고 밝기가 글자 자신이라 대비 1.00:1 이 나온다.
```js
// 캔버스 배경만 있는 영역에서 가장 밝은 픽셀 = 최악의 경우
sharp(shot).extract({ left, top, width, height }).raw()
```
### 씬의 의미는 옆의 콘텐츠가 반응할 때 성립한다 ### 씬의 의미는 옆의 콘텐츠가 반응할 때 성립한다
3D 에 은유를 담았는데 **그 의미를 자막으로 설명하고 있다면, 그림이 스스로 말하지 못한다는 뜻**이다. 3D 에 은유를 담았는데 **그 의미를 자막으로 설명하고 있다면, 그림이 스스로 말하지 못한다는 뜻**이다.