테두리 광을 회전시키고 three.js 모바일 대응을 고친다

회전 테두리
  conic 의 각도를 @property 로 돌리면 매 프레임 배경을 다시 칠한다 —
  컴포지터에서 못 돌고 메인 스레드가 페인트한다(하드 게이트 8).
  정사각형 원뿔 레이어를 마스크 안에서 rotate 시켰다. transform 만 움직인다.
  aspect-ratio: 1 이라야 회전 중 밝기가 일정하고,
  width: 200% 라야 1178x52 헤더에서 모서리까지 닿는다. 주기 11초.

three.js 모바일
  세로 화면에서 게이트가 통째로 안 보이고 있었다.
  수직 시야는 화면 비율과 무관하지만 수평 시야는 aspect 에 비례한다.
  데스크톱(2.5:1)에서 정한 판 폭 15 가 세로 화면(0.45:1)의 수평 시야
  2.5 에 비해 여섯 배라, 첫 판이 화면을 완전히 덮었다.
  fit() 에서 camera.aspect 를 갱신할 때 판의 scale.x 도 같이 잡는다.

  저사양 판정도 고쳤다. hardwareConcurrency <= 4 만 보면 요즘 폰이 전부
  고사양으로 잡힌다(8코어가 흔하다). innerWidth < 760 을 함께 본다.
  DPR 1.5 -> 1.25, 캔버스 불투명도 0.7 -> 0.45(세로는 화면 전체를 덮는다).

스킬
  svg-filters.md  테두리 광 회전은 각도가 아니라 정사각형 레이어 rotate
  three.md        지오메트리를 화면 비율에 맞춰 다시 잡아라
                  저사양 판정에 화면 폭을 넣어라
This commit is contained in:
Yun Chan 2026-08-20 16:37:49 +09:00
parent 54ec6fa582
commit 8267905c0c
8 changed files with 171 additions and 3 deletions

View file

@ -286,6 +286,28 @@ conic 은 중심에서 각도로 도는데, 1180×54 처럼 납작한 형태에
유리가 차갑게만 보이지 않게 하는 정도이고, 밝은 밴드에서는 흰빛 대신 유리가 차갑게만 보이지 않게 하는 정도이고, 밝은 밴드에서는 흰빛 대신
그림자로 뒤집는다(`--glass-rim`·`--glass-bloom``.band-inverse` 에서 재정의). 그림자로 뒤집는다(`--glass-rim`·`--glass-bloom``.band-inverse` 에서 재정의).
### 테두리 광을 회전시켰다
정지한 헤어라인 위에 **도는 빛**을 얹었다. `conic` 의 각도를 `@property` 로 돌리면
매 프레임 배경을 다시 칠하므로(하드 게이트 #8), **정사각형 원뿔 레이어를 마스크 안에서
`rotate` 시킨다.** 움직이는 것은 transform 뿐이다.
`aspect-ratio: 1` 로 정사각형을 유지해야 회전 중 밝기가 일정하고,
`width: 200%` 로 크게 잡아야 1178×52 짜리 헤더에서도 모서리까지 닿는다. 주기 11초.
### three.js 모바일 대응
**세로 화면에서 게이트가 통째로 안 보이고 있었다.** 수직 시야는 화면 비율과 무관하지만
수평 시야는 aspect 에 비례한다 — 데스크톱(2.5:1)에서 정한 판 폭 15 가
세로 화면(0.45:1)의 수평 시야 2.5 에 비하면 **여섯 배**라, 첫 판이 화면을 완전히 덮었다.
`fit()` 에서 `camera.aspect` 를 갱신할 때 판의 `scale.x` 도 같이 잡는다.
그리고 저사양 판정을 고쳤다. `hardwareConcurrency <= 4` 만 보면 **요즘 폰이 전부
고사양으로 잡힌다**(8코어가 흔하다). `innerWidth < 760` 을 함께 본다 —
좁은 화면은 대개 배터리로 돌고 열이 더 난다. DPR 도 1.5 → **1.25**,
캔버스 불투명도도 0.7 → **0.45**(세로에서는 화면 전체를 덮으므로).
### three.js 씬 셋은 서로 다른 것이어야 한다 ### three.js 씬 셋은 서로 다른 것이어야 한다
게이트 씬을 파이프라인으로 옮기면서 히어로에도 그대로 남겨뒀더니 게이트 씬을 파이프라인으로 옮기면서 히어로에도 그대로 남겨뒀더니

View file

@ -17,6 +17,7 @@ const { c } = Astro.props;
<p class="lead mat-lead reveal">{c.lead}</p> <p class="lead mat-lead reveal">{c.lead}</p>
<div class="full stage glass par-slow"> <div class="full stage glass par-slow">
<span class="rim" aria-hidden="true"></span>
<div class="stage-fallback" aria-hidden="true"></div> <div class="stage-fallback" aria-hidden="true"></div>
{/* GPU 가 만드는 재질. WebGL 이 없으면 위 그라디언트가 그대로 결과물이다 */} {/* GPU 가 만드는 재질. WebGL 이 없으면 위 그라디언트가 그대로 결과물이다 */}
<canvas id="mat-canvas" class="stage-canvas" aria-hidden="true"></canvas> <canvas id="mat-canvas" class="stage-canvas" aria-hidden="true"></canvas>

View file

@ -91,6 +91,10 @@ const { c } = Astro.props;
} }
.gate-canvas { opacity: 0; transition: opacity 1s var(--ease-soft); } .gate-canvas { opacity: 0; transition: opacity 1s var(--ease-soft); }
.gate-canvas.is-live { opacity: 0.7; } .gate-canvas.is-live { opacity: 0.7; }
/* 세로 화면에서는 캔버스가 화면 전체를 덮는다. 같은 밝기면 본문이 묻힌다 */
@media (max-width: 760px) {
.gate-canvas.is-live { opacity: 0.45; }
}
/* 통과한 단계가 켜진다. 3D 와 텍스트를 잇는 유일한 연결이다 */ /* 통과한 단계가 켜진다. 3D 와 텍스트를 잇는 유일한 연결이다 */
.stop { transition: opacity var(--dur-quick) var(--ease-out); } .stop { transition: opacity var(--dur-quick) var(--ease-out); }

View file

@ -87,6 +87,7 @@ const FAVICON =
<header class="site-header"> <header class="site-header">
<div class="header-inner glass"> <div class="header-inner glass">
<span class="rim" aria-hidden="true"></span>
<a class="wordmark" href={lang === "ko" ? "/" : "/en/"} aria-label="designpaca"> <a class="wordmark" href={lang === "ko" ? "/" : "/en/"} aria-label="designpaca">
<svg class="mark" viewBox="0 0 32 32" width="20" height="20" aria-hidden="true"> <svg class="mark" viewBox="0 0 32 32" width="20" height="20" aria-hidden="true">
<path d="M11 7v18" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" /> <path d="M11 7v18" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" />

View file

@ -84,11 +84,15 @@ async function boot({ canvas, accentVar, glowVar, onGate, progressFrom }: GateSc
const accent = color(accentVar, "#4FD6C4"); const accent = color(accentVar, "#4FD6C4");
const glow = color(glowVar, "#6E7BFF"); const glow = color(glowVar, "#6E7BFF");
// 저사양이면 판을 줄인다. 코어 수는 거친 지표지만 라이브러리를 더 받지 않는다 // 저사양 판정.
const lean = (navigator.hardwareConcurrency ?? 4) <= 4; // 코어 수만 보면 요즘 폰(8코어)이 전부 고사양으로 잡힌다. 화면 폭을 같이 본다 —
// 좁은 화면은 대개 배터리로 돌아가고, 같은 씬이라도 열이 더 난다.
const narrow = innerWidth < 760;
const lean = narrow || (navigator.hardwareConcurrency ?? 4) <= 4;
const PANE_COUNT = lean ? 3 : 6; const PANE_COUNT = lean ? 3 : 6;
const dpr = Math.min(devicePixelRatio, 1.5); // 좁은 화면에서는 DPR 도 한 단계 더 내린다. 폰의 DPR 은 3 까지 간다.
const dpr = Math.min(devicePixelRatio, narrow ? 1.25 : 1.5);
renderer.setPixelRatio(dpr); renderer.setPixelRatio(dpr);
/* ---- 1) 배경을 렌더 타겟에 그린다 ------------------------- */ /* ---- 1) 배경을 렌더 타겟에 그린다 ------------------------- */
@ -251,6 +255,20 @@ async function boot({ canvas, accentVar, glowVar, onGate, progressFrom }: GateSc
const pointer = new THREE.Vector2(0.5, 0.5); const pointer = new THREE.Vector2(0.5, 0.5);
const pointerTarget = new THREE.Vector2(0.5, 0.5); const pointerTarget = new THREE.Vector2(0.5, 0.5);
// 판이 화면을 덮는 데 필요한 최소 폭.
// 수직 시야는 화면 비율과 무관하지만 수평 시야는 aspect 에 비례한다.
// 데스크톱(2.5:1)에 맞춘 폭 15 를 세로 화면(0.45:1)에 그대로 쓰면
// 수평 시야의 여섯 배가 되어 첫 판이 화면을 완전히 덮는다 —
// 게이트가 보이지 않고 그냥 밝은 화면이 된다.
const GEO_W = 15;
const fitPanes = (aspect: number) => {
const halfH = Math.tan((camera.fov * Math.PI) / 360);
const viewW = 2 * 7.2 * halfH * aspect;
const want = Math.max(6.5, viewW * 1.12);
const sx = Math.min(1, want / GEO_W);
for (const m of panes) m.scale.x = sx;
};
const fit = () => { const fit = () => {
const r = canvas.getBoundingClientRect(); const r = canvas.getBoundingClientRect();
if (!r.width || !r.height) return; if (!r.width || !r.height) return;
@ -262,6 +280,7 @@ async function boot({ canvas, accentVar, glowVar, onGate, progressFrom }: GateSc
camera.aspect = r.width / r.height; camera.aspect = r.width / r.height;
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
bgUniforms.uAspect.value = r.width / r.height; bgUniforms.uAspect.value = r.width / r.height;
fitPanes(camera.aspect);
}; };
fit(); fit();
// window resize 만 듣던 것을 ResizeObserver 로 바꾼다. // window resize 만 듣던 것을 ResizeObserver 로 바꾼다.

View file

@ -247,6 +247,54 @@ a:hover { color: var(--accent); }
(실측: computed backdrop-filter none 된다). (실측: computed backdrop-filter none 된다).
굴절은 재료 섹션의 SVG 타일이 진짜로 보여준다. 여기서는 두께를 만든다. */ 굴절은 재료 섹션의 SVG 타일이 진짜로 보여준다. 여기서는 두께를 만든다. */
/* ---- 테두리를 도는 --------------------------------------
`conic-gradient` 각도를 `@property` 애니메이션하면 프레임 배경을
다시 칠한다 컴포지터에서 돌고 메인 스레드가 페인트한다(하드 게이트 #8).
대신 **정사각형 원뿔 레이어를 마스크 안에서 회전**시킨다. 움직이는 것은
`rotate` 뿐이라 GPU 에서 돈다. 정사각형이라 회전해도 밝기가 균일하고,
요소보다 크게(200%) 잡아 납작한 헤더에서도 모서리까지 닿는다. */
.rim {
position: absolute;
inset: 0;
border-radius: inherit;
overflow: hidden;
pointer-events: none;
padding: 1px;
-webkit-mask:
linear-gradient(black 0 0) content-box,
linear-gradient(black 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(black 0 0) content-box,
linear-gradient(black 0 0);
mask-composite: exclude;
}
.rim::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 200%;
aspect-ratio: 1;
translate: -50% -50%;
background: conic-gradient(
from 0deg,
transparent 0 58%,
color-mix(in oklab, var(--accent) 70%, white) 74%,
transparent 86%
);
opacity: 0.55;
}
@media (prefers-reduced-motion: no-preference) {
.rim::before {
animation: rim-spin 11s linear infinite;
}
}
@keyframes rim-spin {
to { rotate: 360deg; }
}
/* 가장자리를 도는 빛. 유리판 테두리의 굴절을 흉내낸다 */ /* 가장자리를 도는 빛. 유리판 테두리의 굴절을 흉내낸다 */
.glass::after { .glass::after {
content: ""; content: "";

View file

@ -402,3 +402,35 @@ if (reduceEffects()) document.documentElement.classList.add('reduce-effects');
글로우를 더한다면 테두리에서 얕게 번지는 정도로 충분하다 — 글로우를 더한다면 테두리에서 얕게 번지는 정도로 충분하다 —
`0 0 22px -8px` 처럼 **음수 spread** 를 써서 형태 밖으로 새지 않게 한다. `0 0 22px -8px` 처럼 **음수 spread** 를 써서 형태 밖으로 새지 않게 한다.
밝은 배경에서는 흰빛 대신 그림자로 뒤집어야 같은 인상이 난다. 밝은 배경에서는 흰빛 대신 그림자로 뒤집어야 같은 인상이 난다.
### 테두리 광을 회전시키려면
`conic-gradient(from var(--angle), …)``@property` 로 애니메이션하는 방법이 흔히 보이는데,
**그건 매 프레임 배경을 다시 칠한다.** 컴포지터에서 못 돌고 메인 스레드가 페인트한다.
`transform`·`opacity` 만 애니메이션하라는 규칙에 걸린다.
**정사각형 원뿔 레이어를 마스크 안에서 회전시켜라.** 움직이는 것은 `rotate` 뿐이다.
```css
.rim { /* 테두리만 남기는 마스크 */
position: absolute; inset: 0; border-radius: inherit;
overflow: hidden; padding: 1px;
mask: linear-gradient(black 0 0) content-box, linear-gradient(black 0 0);
mask-composite: exclude;
}
.rim::before { /* 실제로 도는 것 */
content: ""; position: absolute; top: 50%; left: 50%;
width: 200%; aspect-ratio: 1; /* 정사각형이라 회전해도 밝기가 균일하다 */
translate: -50% -50%;
background: conic-gradient(from 0deg, transparent 0 58%, var(--glow) 74%, transparent 86%);
animation: rim-spin 11s linear infinite;
}
@keyframes rim-spin { to { rotate: 360deg; } }
```
**두 가지를 지켜라.**
- `aspect-ratio: 1` — 정사각형이어야 회전 중 밝기가 일정하다. 납작하면 변마다 달라진다
- `width: 200%` — 요소보다 크게 잡아야 납작한 형태에서도 모서리까지 닿는다
`prefers-reduced-motion` 에서는 애니메이션만 빼면 된다. 정지 상태의 광은 그대로 남는다.

View file

@ -75,6 +75,47 @@ h1 이 36px 이어도 히어로가 압도적이다.
**파일 이름을 용도와 맞춰라.** `hero-scene.ts` 를 파이프라인에서 쓰기 시작한 순간 **파일 이름을 용도와 맞춰라.** `hero-scene.ts` 를 파이프라인에서 쓰기 시작한 순간
다음 사람은 그것을 히어로 것인 줄 알고 고친다. `gate-scene.ts` 로 바꿔라. 다음 사람은 그것을 히어로 것인 줄 알고 고친다. `gate-scene.ts` 로 바꿔라.
### 지오메트리는 화면 비율에 맞춰 다시 잡아라
데스크톱에서 정한 크기를 세로 화면에 그대로 쓰면 씬이 통째로 안 보인다.
수직 시야는 화면 비율과 무관하지만 **수평 시야는 aspect 에 비례한다.**
```
수직 시야 = 2 · d · tan(fov/2)
수평 시야 = 수직 시야 × aspect
```
실측 사례: 판 폭 15 를 데스크톱(2.5:1)에서 정했다. 세로 화면(0.45:1)에서 수평 시야는
**2.5** 로 줄어드는데 판은 그대로 15 라 **여섯 배**가 됐다. 첫 판이 화면을 완전히 덮어
게이트가 보이지 않고 그냥 밝은 화면이 됐다.
```js
const fitPanes = (aspect) => {
const halfH = Math.tan((camera.fov * Math.PI) / 360);
const viewW = 2 * camDist * halfH * aspect;
const want = Math.max(MIN_W, viewW * 1.12); // 살짝만 덮는다
for (const m of panes) m.scale.x = Math.min(1, want / GEO_W);
};
```
`fit()` 안에서 `camera.aspect` 를 갱신할 때 **같이 부른다.**
### 저사양 판정에 화면 폭을 넣어라
```js
const narrow = innerWidth < 760;
const lean = narrow || (navigator.hardwareConcurrency ?? 4) <= 4;
const dpr = Math.min(devicePixelRatio, narrow ? 1.25 : 1.5);
```
**`hardwareConcurrency` 만 보면 요즘 폰이 전부 고사양으로 잡힌다** — 8코어가 흔하다.
좁은 화면은 대개 배터리로 돌아가고, 같은 씬이라도 열이 더 난다.
폰의 DPR 은 3 까지 가므로 좁은 화면에서는 한 단계 더 내린다.
**세로 화면에서 캔버스가 화면 전체를 덮는다면 불투명도도 낮춰라.**
가로에서 적당하던 밝기가 세로에서는 본문을 묻는다. 실측 교정: `0.7 → 0.45`.
### WebGL 위의 대비는 `readPixels` 로 못 잰다 ### WebGL 위의 대비는 `readPixels` 로 못 잰다
`preserveDrawingBuffer: false`(기본값)면 프레임을 그린 뒤 버퍼가 비워진다. `preserveDrawingBuffer: false`(기본값)면 프레임을 그린 뒤 버퍼가 비워진다.