ステンシルテストを使用して地形に沿ってポリゴンをレンダリングする
2025-01-31

こんにちは。Eukaryaの佐々木です。
弊社では、新しい3D地図エンジンの開発を進めています。地図エンジンとは、Google Earthのように3次元空間上で地球儀を表示し、ズームするとより詳細な画像を表示する仕組みで、Google Earthのような地図としての用途だけでなく、位置情報データのビジュアライゼーションにも活用できます。既存の地図エンジンの例としては、CesiumJSやMapbox GL JSなどがあります。
地図エンジンでは地形の表示が可能です。これにより、現実世界に即したビジュアライゼーションを実現できます。
今回は、地形に沿ってポリゴンや線を描画する表現を実装したので紹介します。
やりたいこと
以下の画像のような複雑な地形の表面に沿って任意のポリゴンを描画したいです。

次の画像がポリゴンを富士山に沿うように描画したものです。これは地形のLODが切り替わっても見た目が変わらずに表現できます。

デモコード
以下のリポジトリにデモコードを置いています。
https://github.com/keiya01/draw-geometries-on-the-terrain
実際に動かしながら記事を読むことで、内容をより理解しやすくなると思います。
https://keiya01.github.io/draw-geometries-on-the-terrain/

どのような方法があるか
このような表現を実装するのは、一見すると簡単そうですが、パフォーマンスなどを考慮すると難易度が高くなります。
一番簡単な方法は、ポリゴンの頂点を地形に合わせて変形させることですが、複雑な形状をしたポリゴンの場合、計算量が膨大になってしまいます。
別の方法として、ポリゴンをテクスチャに書き込んで、そのテクスチャを地形のメッシュに貼り付けるという方法もあります。この方法は一定のケースでは上手くいきそうですが、非常に大きなポリゴンを描画したときに解像度が荒くなってしまったり、多くのメモリを占有してしまうといった問題が起こります。
これらの問題を解決する方法として、ステンシルテストを使用する方法があります。これは以下の2つの論文で紹介されている方法です。
- 「RENDERING 3D VECTOR DATA USING THE THEORY OF STENCIL SHADOW VOLUMES」
- 「Efficient and Accurate Rendering of Vector Data on Virtual Landscapes」
ステンシルテストを使用することで複雑なシェーダの実装やCPUのリソースを使用せずに、ポリゴンを地形に沿わせてレンダリングすることができます。
ステンシルテストとは
ステンシルテストとは、特定のフラグメント(画素)を描画すべきかどうかを判定する仕組みです。
ステンシルテストでは、 以下の条件に基づいて、フラグメントごとにステンシルバッファの値を増減させることができます。
- ステンシルテストに不合格の場合
- 深度テストに不合格の場合
- 深度テストに合格の場合
最後にステンシルバッファの最終値をもとに、そのフラグメントを表示するかを決定します。
ステンシルテストを使用することで、メッシュの重なりに応じて、そのフラグメントを表示すべきかどうかを決めることができます。
ステンシルテストを使用したレンダリングの実装方法
先ほどの論文に基づいて仕組みを紹介します。
まず、以下の画像のように地形(Terrain)を覆うようにポリゴン(Polygon)を構築します。このポリゴンには表面と裏面があります。表面はカメラから見た時に正面に表示される面です。裏面はカメラから見た時に表面によって隠される面です。
このようなポリゴンを構築したら、ステンシルテストを実行します。
まずは裏面からレンダリングして、ステンシルテストを実行します。ステンシルテストに不合格の場合と深度テストに合格した場合は、ステンシルバッファには何も書き込みません。深度テストに不合格の場合、つまりポリゴンの裏面が地形によって隠されるとき、ステンシルバッファに加算します。
次に表面をレンダリングします。表面でも同様に、ステンシルテストに不合格の場合と深度テストに合格した場合はステンシルバッファには何も書き込まず、深度テストに不合格の場合のみステンシルバッファを減算します。
最後に裏面をレンダリングします。この時にステンシルバッファの合計が0ではないフラグメントを表示します。この時に、深度テストを無効にすることで裏面を正面に表示することができます。
なぜ表面ではなく裏面を最後にレンダリングするかというと、カメラがポリゴンの内側にある場合でも正しくステンシルバッファの計算ができるようにするためです。詳細な内容については論文をご参照ください。

実装例は以下のようになります。コード全体はデモ用のリポジトリを参照してください。
// gl: WebGLRenderer, m: Mesh, scene: Scene, camera: Camera
// Back face
m.stencilFunc = AlwaysStencilFunc;
m.stencilFail = KeepStencilOp;
m.stencilZPass = KeepStencilOp;
m.stencilZFail = IncrementWrapStencilOp;
m.side = BackSide;
m.colorWrite = false;
m.depthWrite = false;
m.stencilWrite = true;
m.depthTest = true;
gl.render(scene, camera);
// Front face
m.stencilZFail = DecrementWrapStencilOp;
m.side = FrontSide;
gl.render(scene, camera);
// Final
m.stencilFunc = NotEqualStencilFunc;
m.stencilFail = ZeroStencilOp;
m.stencilZFail = ZeroStencilOp;
m.stencilZPass = ZeroStencilOp;
m.side = BackSide;
m.colorWrite = true;
m.depthTest = false;
gl.render(scene, camera);
陰影をつける
単純にステンシルテストでポリゴンをくり抜くだけでは以下の画像のように陰影がつきません。

陰影をつけるためには地形の法線を利用します。最初に地形の法線のみをレンダリングして、法線マップを取得します。その法線マップから法線を計算してポリゴンへのライティングを計算することで以下のように陰影をつけることができます。

法線マップの計算にはいろいろな方法がありますが、ここではThree.jsのMeshNormalMaterialを使用して、地形の法線を取得しています(コード)。
import { ForwardedRef, forwardRef, useEffect, useRef } from "react";
import { Mesh } from "three";
import { TEXTURE_LOADER } from "./loaders";
const DISPLACEMENT_MAP = TEXTURE_LOADER.load("displacementmap.png");
const DISPLACEMENT_MAP_NORMAL = TEXTURE_LOADER.load("displacementmapnormals.png");
type Props = {
scale?: number
normal?: boolean,
};
export const Terrain = forwardRef(({ scale = 30, normal }: Props, forwardedRef: ForwardedRef<Mesh | null>) => {
const ref = useRef<Mesh | null>();
useEffect(() => {
ref.current?.rotateX(-Math.PI / 2);
}, []);
return (
<mesh ref={(r) => {
if(typeof forwardedRef === "function") {
forwardedRef(r);
} else if(forwardedRef) {
forwardedRef.current = r;
}
ref.current = r;
}}>
<planeGeometry args={[200, 200, 100, 100]} />
{normal
? <meshNormalMaterial
displacementScale={scale}
displacementMap={DISPLACEMENT_MAP}
normalMap={DISPLACEMENT_MAP_NORMAL}
/>
: <meshPhongMaterial
color={0xaaaaaa}
displacementScale={scale}
displacementMap={DISPLACEMENT_MAP}
normalMap={DISPLACEMENT_MAP_NORMAL}
/>
}
</mesh>
)
});
次に、取得した法線マップをポリゴンメッシュに渡します(コード)。
// gl: WebGLRenderer, scene: Scene, mesh: Mesh, enabled?: boolean, normalMap?: Texture
const defaultAutoClear = gl.autoClear;
gl.autoClear = false;
const m = mesh.material;
if (!(m instanceof Material)) return;
if ("normalMap" in m) {
m.normalMap = normalMap;
}
scene.add(mesh);
// この後、ステンシルテストの実行
ポリゴンメッシュでは、Three.jsのMeshLambertMaterialを拡張した、DrapedLambertMaterialを使用しています。
import { MeshLambertMaterial, WebGLProgramParametersWithUniforms } from "three";
export class DrapedLambertMaterial extends MeshLambertMaterial {
onBeforeCompile(parameters: WebGLProgramParametersWithUniforms): void {
parameters.fragmentShader = parameters.fragmentShader.replace(
"#include <normal_fragment_maps>", `
vec2 uv = gl_FragCoord.xy / vec2(textureSize(normalMap, 0));
vec3 mapN = texture2D( normalMap, uv ).xyz * 2.0 - 1.0;
mapN.xy *= 3.;
normal = normalize( mapN );
`);
}
}
このマテリアルでは法線マップから法線を取得するときの処理を上書きして、頂点に基づいたUV座標ではなく、法線マップの相対的な位置から頂点のUVを計算しています。
まとめ
ステンシルテストを使用することで複雑なシェーダ実装を必要とせずに、ポリゴンを複雑な地形に重ねる方法を紹介しました。
また地形から法線マップを取得することで、陰影のレンダリングも簡単に実装できました。
このように、ステンシルテストを使用することで、クリッピング処理のようなさまざまな表現を実装できます。興味のある方は調べてみてください!
Eukaryaでは様々な職種で積極的にエンジニア採用を行っています!OSSにコントリビュートしていただける皆様からの応募をお待ちしております!
Eukarya is hiring for various positions! We are looking forward to your application from everyone who can contribute to OSS!
Eukaryaは、Re:Earthと呼ばれるWebGISのSaaSの開発運営・研究開発を行っています。Web上で3Dを含むGIS(地図アプリの公開、データ管理、データ変換等)に関するあらゆる業務を完結できることを目指しています。ソースコードはほとんどOSSとしてGitHubで公開されています。
➔ Eukarya Webサイト / ➔ note / ➔ GitHub
Eukarya is developing and operating a WebGIS SaaS called Re:Earth. We aim to complete all GIS-related tasks including 3D (such as publishing map applications, data management, and data conversion) on the web. Most of the source code is published on GitHub as OSS.
➔ Eukarya Official Page / ➔ Medium / ➔ GitHub