헤더 배경 수정 + three.js 를 두 곳으로
헤더에 배경이 왜 있냐는 지적에서 시작했다. site-header 자체는 배경이 없었고, 안쪽 유리가 불투명 판으로 폴백된 것이었다. prefers-reduced-transparency 가 참이면 (윈도우 투명 효과 끄기 등) --surface-raised 단색이 깔린다. 투명도를 줄여달라는 요청은 "불투명한 판을 깔아라"가 아니다. 배경에 가까운 톤으로 낮추고 형태는 테두리와 그림자가 유지한다. 떠 있는 헤더는 더 가깝게 맞췄다. three.js - 공용 부트스트랩을 src/lib/shader-plane.ts 로 추출했다. references/three.md 가 권하는 구조를 그대로 구현한 것이다. 지연 로드 · DPR 클램프 1.5 · 화면 밖이면 정지 · 실패 시 CSS 폴백 - 히어로: 광원 두 개가 흐르고 하나가 포인터를 느리게 따라온다 - 재료: 좌표를 노이즈로 밀어 유리 너머의 결을 만든다 - 두 셰이더가 같은 three 청크를 공유한다. 네트워크 요청 1건 --glow-2 를 토큰으로 승격해 셰이더의 하드코딩 색을 없앴다.
This commit is contained in:
parent
be343cdab5
commit
ed9463c3c2
5 changed files with 247 additions and 114 deletions
|
|
@ -89,7 +89,7 @@ const { c, ui } = Astro.props;
|
|||
radial-gradient(46vw 40vw at 22% 18%,
|
||||
color-mix(in oklab, var(--accent) 40%, transparent), transparent 66%),
|
||||
radial-gradient(40vw 38vw at 82% 30%,
|
||||
color-mix(in oklab, #6E7BFF 32%, transparent), transparent 64%);
|
||||
color-mix(in oklab, var(--glow-2) 32%, transparent), transparent 64%);
|
||||
filter: blur(28px);
|
||||
}
|
||||
.hero-canvas { opacity: 0; transition: opacity 1.2s var(--ease-soft); }
|
||||
|
|
@ -156,128 +156,51 @@ const { c, ui } = Astro.props;
|
|||
</style>
|
||||
|
||||
<script>
|
||||
// 배경 셰이더. 이 페이지의 유일한 WebGL 컨텍스트다.
|
||||
// 초기 번들에 three 를 넣지 않고, 첫 화면이 그려진 뒤에 받는다.
|
||||
// LCP 는 h1 이 가져간다. 캔버스는 배경 레이어일 뿐이다.
|
||||
// 배경 셰이더. 광원 두 개가 서로 다른 속도로 흐르고 포인터를 느리게 따라온다.
|
||||
// three 는 초기 번들에 없다. LCP 는 h1 이 가져가고 캔버스는 배경 레이어다.
|
||||
import { mountShaderPlane, GLSL_NOISE } from "../lib/shader-plane";
|
||||
|
||||
const canvas = document.getElementById("hero-canvas") as HTMLCanvasElement | null;
|
||||
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
if (canvas && !reduce) {
|
||||
const kick = () => void start();
|
||||
if ("requestIdleCallback" in window) {
|
||||
(window as unknown as { requestIdleCallback: (cb: () => void, o?: object) => void })
|
||||
.requestIdleCallback(kick, { timeout: 1800 });
|
||||
} else {
|
||||
setTimeout(kick, 900);
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (!canvas) return;
|
||||
let THREE: typeof import("three");
|
||||
try {
|
||||
THREE = await import("three");
|
||||
} catch {
|
||||
return; // 폴백 그라디언트로 남는다
|
||||
}
|
||||
|
||||
let renderer: import("three").WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true });
|
||||
} catch {
|
||||
return; // WebGL 이 없으면 폴백으로 남는다
|
||||
}
|
||||
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
const col = (name: string, fallback: string) =>
|
||||
new THREE.Color((css.getPropertyValue(name).trim() || fallback) as string);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const material = new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uA: { value: col("--accent", "#4FD6C4") },
|
||||
uB: { value: new THREE.Color("#6E7BFF") },
|
||||
uAspect: { value: 1 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
if (canvas) {
|
||||
mountShaderPlane({
|
||||
canvas,
|
||||
pointer: true,
|
||||
speed: 1,
|
||||
rootMargin: "0px",
|
||||
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 < 4; i++) { v += a * noise(p); p *= 2.03; a *= 0.5; }
|
||||
return v;
|
||||
}
|
||||
${GLSL_NOISE}
|
||||
|
||||
void main() {
|
||||
vec2 p = vUv;
|
||||
p.x *= uAspect;
|
||||
float t = uTime * 0.035;
|
||||
float t = uTime * 0.045;
|
||||
|
||||
// 두 광원이 서로 다른 속도로 흐른다
|
||||
float f1 = fbm(p * 1.9 + vec2(t, -t * 0.6));
|
||||
float f2 = fbm(p * 2.4 - vec2(t * 0.8, t * 1.1) + f1 * 0.5);
|
||||
vec2 mouse = uPointer;
|
||||
mouse.x *= uAspect;
|
||||
|
||||
float blobA = smoothstep(0.62, 0.16, distance(p, vec2(0.30 * uAspect, 0.34)) + f1 * 0.34);
|
||||
float blobB = smoothstep(0.58, 0.14, distance(p, vec2(0.78 * uAspect, 0.52)) + f2 * 0.30);
|
||||
float f1 = fbm(p * 1.8 + vec2(t, -t * 0.6));
|
||||
float f2 = fbm(p * 2.3 - vec2(t * 0.8, t * 1.1) + f1 * 0.55);
|
||||
|
||||
vec3 col = uA * blobA + uB * blobB * 0.85;
|
||||
float alpha = clamp(blobA + blobB, 0.0, 1.0) * 0.72;
|
||||
// 첫 광원은 포인터 쪽으로 조금 끌려간다
|
||||
vec2 c1 = mix(vec2(0.30 * uAspect, 0.42), mouse, 0.22);
|
||||
vec2 c2 = vec2(0.80 * uAspect, 0.30);
|
||||
|
||||
float a = smoothstep(0.70, 0.10, distance(p, c1) + f1 * 0.36);
|
||||
float b = smoothstep(0.64, 0.08, distance(p, c2) + f2 * 0.32);
|
||||
|
||||
vec3 col = uA * a + uB * b * 0.9;
|
||||
float alpha = clamp(a * 1.15 + b, 0.0, 1.0) * 0.85;
|
||||
gl_FragColor = vec4(col, alpha);
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material));
|
||||
|
||||
const fit = () => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
if (!r.width || !r.height) return;
|
||||
// DPR 클램프. 3 을 그대로 쓰면 픽셀이 네 배가 된다
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
|
||||
renderer.setSize(r.width, r.height, false);
|
||||
material.uniforms["uAspect"]!.value = r.width / r.height;
|
||||
};
|
||||
fit();
|
||||
addEventListener("resize", fit, { passive: true });
|
||||
|
||||
let visible = true;
|
||||
document.addEventListener("visibilitychange", () => { visible = !document.hidden; });
|
||||
// 히어로가 화면 밖으로 나가면 그리지 않는다
|
||||
let onScreen = true;
|
||||
new IntersectionObserver(
|
||||
(es) => { onScreen = es.some((e) => e.isIntersecting); },
|
||||
{ threshold: 0 },
|
||||
).observe(canvas);
|
||||
|
||||
canvas.classList.add("is-live");
|
||||
renderer.setAnimationLoop((ms) => {
|
||||
if (!visible || !onScreen) return;
|
||||
material.uniforms["uTime"]!.value = ms * 0.001;
|
||||
renderer.render(scene, camera);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ const { c } = Astro.props;
|
|||
|
||||
<div class="full stage glass">
|
||||
<div class="stage-fallback" aria-hidden="true"></div>
|
||||
{/* GPU 가 만드는 재질. WebGL 이 없으면 위 그라디언트가 그대로 결과물이다 */}
|
||||
<canvas id="mat-canvas" class="stage-canvas" aria-hidden="true"></canvas>
|
||||
|
||||
<ul class="tiles">
|
||||
{
|
||||
|
|
@ -87,11 +89,12 @@ const { c } = Astro.props;
|
|||
max-width: 1180px;
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
padding: var(--space-6) var(--space-4);
|
||||
padding: var(--space-5) var(--space-4);
|
||||
border-radius: var(--radius-shell);
|
||||
overflow: hidden;
|
||||
}
|
||||
.stage-fallback {
|
||||
.stage-fallback,
|
||||
.stage-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
|
|
@ -106,6 +109,9 @@ const { c } = Astro.props;
|
|||
var(--surface-raised);
|
||||
}
|
||||
|
||||
.stage-canvas { opacity: 0; transition: opacity var(--dur-slow) var(--ease-soft); }
|
||||
.stage-canvas.is-live { opacity: 1; }
|
||||
|
||||
/* 3개 항목에 3개 셀. 빈 칸이 생기지 않고 균등 3열도 아니다 */
|
||||
.tiles {
|
||||
list-style: none;
|
||||
|
|
@ -122,8 +128,8 @@ const { c } = Astro.props;
|
|||
.tile:nth-child(2) { grid-area: top; }
|
||||
.tile:nth-child(3) { grid-area: bottom; }
|
||||
/* 큰 셀은 더 넓은 화면을 갖는다 */
|
||||
.tile:nth-child(1) .swatch { aspect-ratio: 5 / 4; }
|
||||
.tile:nth-child(n + 2) .swatch { aspect-ratio: 16 / 7; }
|
||||
.tile:nth-child(1) .swatch { aspect-ratio: 16 / 11; }
|
||||
.tile:nth-child(n + 2) .swatch { aspect-ratio: 16 / 6; }
|
||||
.tile { display: grid; gap: var(--space-2); align-content: start; }
|
||||
.tile .shell { box-shadow: var(--glass-hi), var(--shadow-2); }
|
||||
.tile .core { overflow: hidden; }
|
||||
|
|
@ -156,3 +162,47 @@ const { c } = Astro.props;
|
|||
.tile:nth-child(n + 2) .swatch { aspect-ratio: 5 / 4; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 무대 배경. 유리 표면을 지나는 빛의 굴절을 흉내낸다.
|
||||
// 히어로와 같은 모듈, 같은 three 청크를 쓴다. 새로 받는 것은 없다.
|
||||
import { mountShaderPlane, GLSL_NOISE } from "../lib/shader-plane";
|
||||
|
||||
const canvas = document.getElementById("mat-canvas") as HTMLCanvasElement | null;
|
||||
if (canvas) {
|
||||
mountShaderPlane({
|
||||
canvas,
|
||||
speed: 0.7,
|
||||
colors: { uA: "--accent", uB: "--glow-2" },
|
||||
fragment: `
|
||||
precision mediump float;
|
||||
varying vec2 vUv;
|
||||
uniform float uTime;
|
||||
uniform float uAspect;
|
||||
uniform vec3 uA;
|
||||
uniform vec3 uB;
|
||||
${GLSL_NOISE}
|
||||
|
||||
void main() {
|
||||
vec2 p = vUv;
|
||||
p.x *= uAspect;
|
||||
float t = uTime * 0.06;
|
||||
|
||||
// 좌표 자체를 노이즈로 밀어 굴절처럼 보이게 한다
|
||||
vec2 warp = vec2(fbm(p * 2.2 + t), fbm(p * 2.2 - t + 4.7));
|
||||
vec2 q = p + (warp - 0.5) * 0.42;
|
||||
|
||||
// 밀린 좌표 위에 얇은 띠를 얹는다. 유리 너머의 결이다
|
||||
float band = sin((q.x + q.y) * 7.0 + uTime * 0.35) * 0.5 + 0.5;
|
||||
band = smoothstep(0.35, 0.95, band);
|
||||
|
||||
float depth = smoothstep(0.1, 1.0, fbm(q * 1.6 - t * 0.5));
|
||||
|
||||
vec3 col = mix(uB, uA, depth);
|
||||
float alpha = (0.16 + band * 0.22) * (0.55 + depth * 0.45);
|
||||
gl_FragColor = vec4(col, alpha);
|
||||
}
|
||||
`,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
150
apps/site/src/lib/shader-plane.ts
Normal file
150
apps/site/src/lib/shader-plane.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* 셰이더 플레인 부트스트랩.
|
||||
*
|
||||
* 스킬의 references/three.md 가 권하는 그 구조다.
|
||||
* - three 는 초기 번들에 없다. 화면에 가까워질 때만 받는다
|
||||
* - WebGL 이 없거나 모션을 줄이는 설정이면 아예 받지 않는다
|
||||
* - DPR 를 클램프한다. 3 을 그대로 쓰면 픽셀이 네 배가 된다
|
||||
* - 화면 밖이거나 탭이 숨으면 그리지 않는다
|
||||
* - 실패하면 조용히 물러난다. CSS 폴백이 그대로 결과물이 된다
|
||||
*
|
||||
* 무거운 씬을 임포트하지 않고 평면 하나에 프래그먼트 셰이더만 얹는다.
|
||||
*/
|
||||
|
||||
export interface ShaderPlaneOptions {
|
||||
canvas: HTMLCanvasElement;
|
||||
/** GLSL 프래그먼트 셰이더. vUv, uTime, uAspect, uPointer 를 받는다 */
|
||||
fragment: string;
|
||||
/** CSS 커스텀 프로퍼티에서 읽어 vec3 로 넘길 색 */
|
||||
colors?: Record<string, string>;
|
||||
/** 포인터를 따라가게 할지. 히어로처럼 큰 면적에만 쓴다 */
|
||||
pointer?: boolean;
|
||||
/** 시간 진행 속도 배수 */
|
||||
speed?: number;
|
||||
/** 미리 받아둘 거리 */
|
||||
rootMargin?: string;
|
||||
}
|
||||
|
||||
const VERTEX = `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
/** 두 셰이더가 공유하는 값 노이즈. 라이브러리를 끌어오지 않는다 */
|
||||
export const GLSL_NOISE = `
|
||||
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 < 4; i++) { v += a * noise(p); p *= 2.03; a *= 0.5; }
|
||||
return v;
|
||||
}
|
||||
`;
|
||||
|
||||
export function mountShaderPlane(opts: ShaderPlaneOptions): void {
|
||||
const { canvas } = opts;
|
||||
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries.some((e) => e.isIntersecting)) return;
|
||||
io.disconnect();
|
||||
void boot(opts);
|
||||
},
|
||||
{ rootMargin: opts.rootMargin ?? "300px 0px" },
|
||||
);
|
||||
io.observe(canvas);
|
||||
}
|
||||
|
||||
async function boot(opts: ShaderPlaneOptions): Promise<void> {
|
||||
const { canvas, fragment, colors = {}, pointer = false, speed = 1 } = opts;
|
||||
|
||||
let THREE: typeof import("three");
|
||||
try {
|
||||
THREE = await import("three");
|
||||
} catch {
|
||||
return; // CSS 폴백이 그대로 결과물이 된다
|
||||
}
|
||||
|
||||
let renderer: import("three").WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true });
|
||||
} catch {
|
||||
return; // WebGL 이 없는 환경
|
||||
}
|
||||
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
const uniforms: Record<string, { value: unknown }> = {
|
||||
uTime: { value: 0 },
|
||||
uAspect: { value: 1 },
|
||||
uPointer: { value: new THREE.Vector2(0.5, 0.5) },
|
||||
};
|
||||
for (const [name, cssVar] of Object.entries(colors)) {
|
||||
const raw = css.getPropertyValue(cssVar).trim();
|
||||
uniforms[name] = { value: new THREE.Color(raw || "#4FD6C4") };
|
||||
}
|
||||
|
||||
const material = new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
uniforms,
|
||||
vertexShader: VERTEX,
|
||||
fragmentShader: fragment,
|
||||
});
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material));
|
||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const fit = () => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
if (!r.width || !r.height) return;
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
|
||||
renderer.setSize(r.width, r.height, false);
|
||||
(uniforms["uAspect"] as { value: number }).value = r.width / r.height;
|
||||
};
|
||||
fit();
|
||||
addEventListener("resize", fit, { passive: true });
|
||||
|
||||
if (pointer) {
|
||||
const target = new THREE.Vector2(0.5, 0.5);
|
||||
addEventListener(
|
||||
"pointermove",
|
||||
(e) => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
target.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
// 매 프레임 목표값으로 조금씩 따라간다. 즉시 반응하면 값싸 보인다
|
||||
material.userData["follow"] = () => {
|
||||
const p = uniforms["uPointer"] as { value: import("three").Vector2 };
|
||||
p.value.lerp(target, 0.045);
|
||||
};
|
||||
}
|
||||
|
||||
let tabVisible = true;
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
tabVisible = !document.hidden;
|
||||
});
|
||||
|
||||
let onScreen = true;
|
||||
new IntersectionObserver((es) => {
|
||||
onScreen = es.some((e) => e.isIntersecting);
|
||||
}).observe(canvas);
|
||||
|
||||
canvas.classList.add("is-live");
|
||||
renderer.setAnimationLoop((ms) => {
|
||||
if (!tabVisible || !onScreen) return;
|
||||
(material.userData["follow"] as (() => void) | undefined)?.();
|
||||
(uniforms["uTime"] as { value: number }).value = (ms * 0.001) * speed;
|
||||
renderer.render(scene, camera);
|
||||
});
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ body::before {
|
|||
radial-gradient(58vw 46vw at 14% -6%,
|
||||
color-mix(in oklab, var(--accent) 26%, transparent), transparent 68%),
|
||||
radial-gradient(52vw 44vw at 92% 8%,
|
||||
color-mix(in oklab, #6E7BFF 20%, transparent), transparent 66%),
|
||||
color-mix(in oklab, var(--glow-2) 20%, transparent), transparent 66%),
|
||||
radial-gradient(70vw 60vw at 62% 108%,
|
||||
color-mix(in oklab, var(--accent) 14%, transparent), transparent 70%);
|
||||
}
|
||||
|
|
@ -210,13 +210,18 @@ a:hover { color: var(--accent); }
|
|||
mask-composite: exclude;
|
||||
}
|
||||
|
||||
/* 투명도를 줄여달라는 사용자에게는 불투명 판으로 준다 */
|
||||
/* 투명도를 줄여달라는 요청에 불투명한 판을 깔면 그냥 상자가 된다.
|
||||
배경에 가까운 톤으로 낮추고 형태는 테두리와 그림자가 유지한다. */
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.glass {
|
||||
background: var(--surface-raised);
|
||||
background: color-mix(in oklab, var(--surface) 84%, var(--ink));
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
/* 떠 있는 헤더는 배경과 더 가깝게. 여기 판이 깔리면 즉시 눈에 띈다 */
|
||||
.site-header .glass {
|
||||
background: color-mix(in oklab, var(--surface) 93%, var(--ink));
|
||||
}
|
||||
}
|
||||
@supports not (backdrop-filter: blur(1px)) {
|
||||
.glass { background: var(--surface-raised); }
|
||||
|
|
@ -241,7 +246,10 @@ a:hover { color: var(--accent); }
|
|||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.06);
|
||||
}
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.shell { background: var(--surface-raised); backdrop-filter: none; }
|
||||
.shell {
|
||||
background: color-mix(in oklab, var(--surface) 88%, var(--ink));
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 진입 연출 ----------------------------------------------
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@
|
|||
--accent-ink: #04211D;
|
||||
--accent-inverse: #0E7A6E;
|
||||
--accent-ink-inverse: #FFFFFF;
|
||||
/* 셰이더가 쓰는 두 번째 광색. 강조색이 아니라 광원이다 */
|
||||
--glow-2: #6E7BFF;
|
||||
|
||||
/* ---- 유리 ------------------------------------------------
|
||||
backdrop-filter 만으로는 흐린 유리다. 굴절은 SVG 가 만든다. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue