ThreeJS에서 기본 구성 요소인 Mesh는 Geometry와 Material을 children으로 가진다. Geometry의 경우 이전 포스팅에서 개념과 실습을 다루어보았다.
웹 퍼블리싱과 비유하자면, Geometry는 HTML, Material은 CSS에 해당한다.
즉, 화면에 그릴 때 뼈대를 구성하는 것은 Geometry, 그 외 색, 질감, 빛 표현 등의 스타일을 구성하는 것이 Material인 것이다.
- color
- visible
- transparent
- opacity
- side
- alphaTest
- depthTest
- depthWrite
물론 이외에도 더 다양한 property가 존재한다. 그것은 공식 문서를 참고하면 도움이 될 것이다.
color
?: Color | undefined
THREE.Color
해당 타입은 여러 방법으로 세팅이 가능하다.
<meshBasicMaterial
// 1. hex 값
color={0x008000}
// 2. 문자열
color="green"
// 3. hex 문자열
color="#008000"
// 4. rgb 문자열
color="rgb(0, 128, 0)"
// 5. hsl 문자열
color="hsl(120, 100%, 25%)"
/>
다양한 방법이 있지만 개인적으로 hex 문자열이 가장 편한 것 같다. Figma에서도 바로 추적이 가능한 것이 가장 큰 장점이고, VSCode 상에서 어떤 색인지도 확인할 수 있다.
visible
?: boolean | undefinedtransparent
?: boolean | undefinedopacity
?: number | undefinedside
?: THREE.Side | undefinedalphaTest
?: number | undefineddepthTest
?: boolean | undefineddepthWrite
?: boolean | undefineddepthTest와 depthWrite의 의미 자체도 모호하거니와, 해당 값을 각각 enable, disable 해 보아도 두 옵션의 차이를 크게 느낄 수 없었다.
이 둘의 차이점을 알기 위해서 R3F의 기반인 ThreeJS가 3D를 웹에 렌더하기 위해 쓰는 API인 WebGL
이 depth 정보를 어떻게 관리하는지부터 알아보았다.
depth buffer는 왜 필요한가
depth buffer(또는 Z-Buffer)는 깊이 정보를 저장하는 데 사용된다. , 객체들이(ThreeJS의 경우 Object3D class) 화면에 어떤 순서로 그려지는지 결정한다.
depth buffer는 어떻게 계산되는가
depth buffer의 깊이 정보는 최소 16비트로 0.0~1.0까지의 정보로 표현된다. 깊이 값은 아래 수식으로 계산된다.
여기서 z는 특정 포인트와 카메라 사이의 z 값, near는 시야 체적(visible frustum)의 좁은 면, far는 시야 체적의 넓은 면을 의미한다. 즉, 카메라와 객체 각 지점의 거리를 시야 체적 내에서 정규화된 값이다.
👩💻 왜 depth buffer는 non-linear한 값인가요?
이는 실제 사람이 거리감을 인식할 때와 관련이 있다. 예를 들어 시점 기준으로 <10cm 앞에 있는 것과 30cm 앞에 있는 것의 차이>와 <100m 앞에 있는 것과 100m 20cm 앞에 있는 것의 차이>를 비교한다고 할 때, 둘 다 같은 20cm 차이이지만 보는 것에 있어서 전자는 후자에 비해 크게 보이는 것이다. webGL에서 3D를 표현할 때도 해당 거리감의 차이를 적용한 것이다. 즉, 가까이 있는 것에 대한 거리감을 더 크게 적용하기 위함이다.
depth buffer는 어떻게 기록되는가
위에서 계산된 깊이 정보는 렌더될 때마다 depth buffer에 기록된다.
객체가 렌더링 될 때 일어나는 일
WebGL은 color buffer를 그리기 전, 해당 픽셀 z축에 존재하는 depth buffer를 확인한다. depth buffer가 더 작은 값에 해당하는 것을 렌더한다.
그래서 depthTest는 뭐고, depthWrite는 무엇인가?
depthTest는 렌더될 때 현재 깊이 값을 기존의 depth buffer와 비교할지 결정하는 옵션이다. depthTest가 활성화되어 있으면 새로운 깊이 값이 기존 깊이 값과 비교되고, 이 비교에서 통과되는 경우(=== depth buffer가 더 작으면 === 카메라 기준 더 가까우면) 해당 픽셀이 화면에 그려진다.
depthWrite는 새롭게 렌더될 때 깊이 값을 depth buffer에 기록할지 여부를 제어하는 옵션이다.
depthWrite가 활성화되어 있으면 새로운 깊이 값이 depth buffer에 기록되고, 다음 프레임 때 사용한다.
최종 시나리오 비교
depthTest=true | depthTest=false | |
---|---|---|
depthWrite=true | - 깊이 테스트가 활성화됨 + 새로운 픽셀의 깊이 값이 기록됨 - 기본값. 항상 실제 눈에 보이는 대로 렌더됨 | - 깊이 테스트가 비활성화됨 + 새로운 픽셀의 깊이 값이 기록됨 - 모든 픽셀이 렌더되고, 이미 존재하는 픽셀의 뒤에 있는 새로운 픽셀도 위에 렌더됨 |
depthWrite=false | - 깊이 테스트가 활성화됨 + 새로운 픽셀은 깊이 값이 기록되지 않음 - 새로운 픽셀이 다음 프레임에서 같은 위치에 렌더되더라도 해당 픽셀의 깊이 값은 변하지 않고 이전 프레임의 값이 유지 - ex) 투명 물체나 화면에 나타나지 않아야 하는 객체를 다룰 때 유용 | - 깊이 테스트가 비활성화됨 + 새로운 픽셀은 깊이 값이 기록되지 않음 - 다음 프레임에서 같은 위치에 있는 다른 픽셀이 이전 픽셀을 덮어쓸 수 있음 - 즉, 그려지는 순서에 따라 포개어지고, 거리와 상관없이 가장 나중에 그려지는 객체가 가장 위에 그려짐 |
다음은 particle을 배치하기 위해 배경이 투명한 png asset을 pointsMaterial
을 이용한 예제이다.
그런데 딱 봐도 뭔가 이상하다. png의 배경이 투명임에도 투명으로 보이지 않고, 불투명하게 렌더되고, 뒤의 객체를 가리고 있다. 이 이유는 asset이 투명하더라고 texture가 있는 asset의 사각 평면의 깊이 정보가 depth buffer에 기록되기 때문이다.
이런 문제를 해결하기 위한 두 가지 방법을 아래 제시해 두었다.
01) alphaTest 이용
위에서 언급했듯이, alphaTest를 통해 객체의 opacity가 alphaTest 값 이하인 경우는 보이지 않도록 렌더할 수 있다. 따라서 0.5 정도로 잡으면 가장 처음에 발생한 문제는 해결할 수 있다. 다만 원래 asset이 가지고 있는 형태가 alphaTest로 인해 일그러뜨려져 보인다.
02) depthWrite 이용
다른 방법으로는 depthWrite를 false로 적용하는 것이다. 이렇게 설정하면, 새로운 픽셀이 렌더되더라도 depth buffer 값이 기록되지 않기 때문에 불투명하게 렌더되는 것을 막을 수 있다. 하지만 단점으로는 particle이 렌더되는 순서대로 그려지기 때문에 가장 가깝게 위치한 particle이라도 뒤에 위치한 particle을 가리지 못하는 경우가 생긴다.
<참고 자료>
∙ Three.js - depthWrite vs depthTest for transparent canvas texture map on THREE.Points - stackoverflow
∙ Tackling transparent textures in threejs - Wooden Raft
∙ The magical world of Particles with React Three Fiber and Shaders - blog.maximeheckel.com
빛의 영향을 받지 않는 material.
그 외의 material은 빛의 영향을 받는다.
반짝이지 않는 표면을 구현할 때 주로 쓰이는 material.
후술할 Phong, Standard, Physical보다 반사, 조명 모델이 간단하여 그래픽 정확도의 비용이 덜 든다.
emissive
?: Color | undefinednew THREE.Color( 0x000000 )
(검정)반사광이 있는 표면을 구현할 때 쓰는 material.
두 Material은 서로 다른 반사 모델을 가진다. 반사 모델이란 렌더된 재질의 표면에 대한 빛과 그림자의 표현을 제어한다.
import { useEffect, useRef } from "react";
import * as THREE from "three";
const BasicLambertPhong = () => {
const groupRef = useRef<THREE.Group>(null);
const meshRef = useRef<THREE.Mesh>(null);
useEffect(() => {
for (let i = 0; i < groupRef.current!.children.length; i++) {
const mesh = groupRef.current!.children[i] as THREE.Mesh;
mesh.geometry = meshRef.current!.geometry;
if (mesh.material instanceof THREE.MeshBasicMaterial) {
mesh.position.z = 0;
mesh.position.x = 0;
} else if (mesh.material instanceof THREE.MeshLambertMaterial) {
mesh.position.z = 2;
mesh.position.x = (i - 1) * 2;
} else if (mesh.material instanceof THREE.MeshPhongMaterial) {
mesh.position.z = 4;
mesh.position.x = (i - 4) * 2;
}
}
}, []);
return (
<>
<directionalLight position={[5, 5, 5]} />
<group ref={groupRef}>
{/* meshBasicMaterial */}
<mesh ref={meshRef}>
<torusKnotGeometry args={[0.5, 0.2]} />
<meshBasicMaterial color="green" />
</mesh>
{/* meshLambertMaterial */}
<mesh>
<meshLambertMaterial color="green" />
</mesh>
<mesh>
<meshLambertMaterial color="green" emissive={"red"} />
</mesh>
<mesh>
<meshLambertMaterial color="green" />
</mesh>
{/* meshPhongMaterial */}
<mesh>
<meshPhongMaterial color="green" />
</mesh>
<mesh>
<meshPhongMaterial color="green" emissive={"red"} />
</mesh>
<mesh>
<meshPhongMaterial color="green" specular={"white"} shininess={100} />
</mesh>
</group>
</>
);
};
export default BasicLambertPhong;
물리 기반 렌더링(PBR. Physical Based Rendering)이 적용된 material.
❓ 물리 기반 렌더링
물리적인 원리에 기반하여 물체의 실제 광학적 특성을 더 정확하게 시뮬레이션하여 현실감 있는 결과를 나타내는 렌더링 기법. 더 복잡하고 정밀하여, 계산량이 더 많고 렌더링 엔진에 높은 성능을 요구한다.
❓ 비물리 기반 렌더링
실제 광학성 특성을 모델링하는 것이 아니라 그림자, 하이라이트 및 광택을 간단하게 시뮬레이션하기 위한 렌더링 기법. 전술한 Lambert, Phong이 해당 렌더링 기법을 따른다.
Clearcoat
, Physically-based transparency
, Advanced reflectivity
, Sheen
property를 추가한 것이 바로 PhysicalMaterial이 된다.const Lecture31 = () => {
// ...
return (
<>
<directionalLight position={[5, 5, 5]} intensity={5} />
<mesh position={[0, 0, 0]}>
<torusKnotGeometry args={[0.5, 0.2]} />
<meshBasicMaterial color="gainsboro" />
</mesh>
<group>
<mesh>
<meshStandardMaterial color="gainsboro" roughness={0} metalness={1} />
</mesh>
<mesh>
<meshStandardMaterial color="gainsboro" roughness={1} metalness={1} />
</mesh>
<mesh>
<meshStandardMaterial color="gainsboro" roughness={0} metalness={0} />
</mesh>
<mesh>
<meshStandardMaterial color="gainsboro" roughness={1} metalness={0} />
</mesh>
<mesh>
<meshPhysicalMaterial color="gainsboro" roughness={0} metalness={1} />
</mesh>
<mesh>
<meshPhysicalMaterial
color="gainsboro"
roughness={1}
metalness={1}
clearcoat={1}
clearcoatRoughness={0}
/>
</mesh>
<mesh>
<meshPhysicalMaterial
color="gainsboro"
roughness={0}
metalness={0}
clearcoat={1}
clearcoatRoughness={0}
transparent={true}
// 투명도 조절
transmission={0.8}
// 유리 두께
thickness={controls.thickness}
// 굴절률
ior={2.33}
/>
</mesh>
<mesh>
<meshPhysicalMaterial
color="gainsboro"
roughness={1}
metalness={0}
clearcoat={1}
clearcoatRoughness={0}
transparent={true}
// 투명도 조절
transmission={0.9}
// 유리 두께
thickness={controls.thickness}
// 굴절률
ior={2.33}
/>
</mesh>
</group>
</>
);
};
export default Lecture31;
💡 PhysicalMaterial에서
thickness
조절 시roughness
가 1 미만이어야 함
아래 두 material은 기본 color 속성이 없는 material이다.
원하는 색을 입힐 수도, 빛에 영향을 받는 material도 아니기에 아직은 특별히 쓸 일이 없는 것 같다.
normal vector(법선 벡터) 값을 RGB로 나타낸 material
객체의 표면에서 수직으로 나아가는 벡터를 RGB로 그대로 옮겨 해당 vertex의 color가 지정되는 것이다.
별다른 설정을 하지 않은 경우 법선 벡터는 카메라의 좌표계를 따라간다. 그래서 항상 보이는 면에서 y축 방향과 수직인 vertex는 초록색을, z축 방향과 수직인 vertex는 파란색을, x축 방향과 수직인 verte는 빨간색을 띤다.
빛과는 상관없이 Depth 값에 의해 그려지는 material.
depth는 카메라와 객체가 렌더되는 위치와의 거리를 나타낸 값이므로, 카메라의 zoom 정도에 따라서 밝기가 달라진다.
import { useHelper } from "@react-three/drei";
import { useRef } from "react";
import { VertexNormalsHelper } from "three/examples/jsm/Addons.js";
import * as THREE from "three";
const NormalDepth = () => {
const normalRef = useRef<THREE.Mesh>(null!);
useHelper(normalRef, VertexNormalsHelper);
return (
<>
<directionalLight position={[5, 5, 5]} />
<group>
<mesh position={[0, 0, 0]} ref={normalRef}>
<torusKnotGeometry args={[0.5, 0.2]} />
<meshNormalMaterial />
</mesh>
<mesh position={[0, 0, 2]}>
<torusKnotGeometry args={[0.5, 0.2]} />
<meshDepthMaterial />
</mesh>
</group>
</>
);
};
export default NormalDepth;
원하는 표면을 덮어씌울 수 있는 material.
스포일러이지만, 현재 공부하면서 하고 싶은 프로젝트를 진행하고 있는데, 이 material을 애용하게 됐다. 기본 geometry가 아닌 원하는 모델을 gltf 파일로 가져와서 그 위에 원하는 표면 디자인을 얹기 위해서이다.
금방 관련 프로젝트에 대한 글도 포스팅할 예정이니, 많은 기대 부탁한다!
만화 느낌이 나도록 만드는 material.
import { useTexture } from "@react-three/drei";
import { useEffect, useRef } from "react";
import * as THREE from "three";
const MatcapMaterial = ({ num }: { num: number }) => {
const matcap = useTexture(`./imgs/matcap${num}.jpeg`);
return (
<mesh>
<meshMatcapMaterial matcap={matcap} />
</mesh>
);
};
const Lecture32 = () => {
const meshRef = useRef<THREE.Mesh>(null);
const groupRef = useRef<THREE.Group>(null);
const tone5 = useTexture(`./imgs/fiveTone.jpg`);
tone5.minFilter = THREE.NearestFilter;
tone5.magFilter = THREE.NearestFilter;
const tone3 = useTexture(`./imgs/threeTone.jpg`);
tone3.minFilter = THREE.NearestFilter;
tone3.magFilter = THREE.NearestFilter;
useEffect(() => {
const meshLength = groupRef.current!.children.length;
for (let i = 0; i < meshLength; i++) {
const mesh = groupRef.current!.children[i] as THREE.Mesh;
mesh.geometry = meshRef.current!.geometry;
mesh.position.x = i * 2;
mesh.position.z = 2;
if (i >= 4) {
mesh.position.x = (i - 4) * 2;
mesh.position.z = 4;
}
}
});
return (
<>
<directionalLight position={[5, 5, 5]} />
<mesh ref={meshRef} position={[0, 0, 0]}>
<torusKnotGeometry args={[0.5, 0.2]} />
</mesh>
<group ref={groupRef}>
{[1, 2, 3, 4].map((ele) => (
<MatcapMaterial key={ele} num={ele} />
))}
<mesh>
<meshToonMaterial gradientMap={tone5} color="pink" />
</mesh>
<mesh>
<meshToonMaterial gradientMap={tone3} color="pink" />
</mesh>
</group>
</>
);
};
export default Lecture32;
이전 포스팅과는 달리 궁금한 점이 많이 생겼었고, 더 심도 있게 접근하려고 노력했다. 그래서 원래 업로드 하려고 했던 일자보다 일주일 미뤄져서 업로드하게 됐다. 뿌듯하면서도 한편으로는 물음표를 어디까지 매달아야 하는 것인가 하는 고민도 하게 됐다.
그리고 R3F를 잘하기 위해서는 결국 ThreeJS의 기본을 알고 있어야 하고, ThreeJS를 잘하기 위해서는 결국 webGL의 구성에 대해 알고 있어야 했다. 이번에는 특히나 depth buffer에 대해서 파헤치는 시간을 오래 가졌는데, 궁금하고 의아한 부분이 해소될 때마다 다른 속성에 대한 webGL의 구동 방식이 궁금해졌다.
React, JavaScript와 달리 R3F는 레퍼런스, 공식문서가 부족한 편이어서 시간이 더욱 오래 걸렸다고 생각한다. 막막할 때도 있었지만 긍정적으로 생각해 보면 내가 곧 레퍼런스가 될 수 있지 않나
라는 마음으로 더욱 집요하게 공부했다.
그래서 혹시나 해당 포스트를 보면서 이해가 되지 않는 부분이나 틀린 부분, 의견이 다른 부분이 있다면 언제든지 댓글로 남겨주길 바란다.
현재 이론 강의는 전부 수강했고, 프로젝트 실습 강의만 남았다. 완강이란 것을 하는 게 오랜만이라 설레면서 스스로 자랑스럽다.