はじめに
先月のCGWORLDクリエイティブカンファレンス SideFX Houdiniアップデート情報の中で、平面にシェーダでボックス状の奥行きをつくり、インテリアの画像を貼り付けることでモデリング無しで部屋を作る技術を紹介していました。
HoudiniではKarma Room Map VOPと呼ばれている他、Fake Interiorと呼ばれることもあります。
いわゆるParallax mapping(視差マッピング)を拡張したもので、聞いたことはありましたが実際に試したことが無く、今回Babylon.js、GLSL、Shader Materialを用いて実装してみました。
ちなみに普通のParallax mappingであればStandardMaterial / PBRMaterialで標準対応しています。
必要な素材
-
HDRもしくはEXRの室内インテリア画像
wParallax
wParallax無料版
Evermotion OSL Parallax maps
※ 今回はこの界隈で標準化しているフォーマットを使っていますが、シェーダをライトマップに対応させればwebp等でも可能でしょうし、インテリア画像を一つにせず、バラバラの素材を使うことも可能です。 -
パノラマHDR画像
Poly Haven
ambientCG
実装
実装の範囲
- 部屋の生成とテクスチャの割り当て
- 内部に平面オブジェクトを生成して家具配置
- ライトの明るさ設定
- パノラマHDR画像でライティング(Image-based lighting 通称IBL)
コードの紹介
VertexShader
// 浮動小数点計算の精度 highp, mediump, lowp
precision mediump float;
// 入力属性
attribute vec3 position; // 頂点の位置
attribute vec2 uv; // UV座標
attribute vec3 normal; // 法線ベクトル
// VertexShaderに渡される変数
uniform mat4 worldViewProjection; // ワールド、ビュー、プロジェクションの3つの変換を合成した行列。3D空間の座標をカメラから見える範囲(クリップ空間)に変換して計算コスト削減
uniform mat4 world; // ワールド行列。オブジェクトの位置、回転、スケールを定義する変換行列
uniform vec3 vEyePosition; // 視点(カメラ)の位置
// VertexShaderからFragmentShaderに渡す変数
varying vec3 vNormal;
varying vec3 vPosition;
// 頂点の処理を実行
void main(void) {
gl_Position = worldViewProjection * vec4(position, 1.0); // ワールド空間座標をクリップ空間に変換
vPosition = position;
vNormal = normal;
}
精度に関する注意点
フラグメントシェーダで highp 精度を使うのはやめましょう。mediump を代わりに使いましょう。highp を使うと今のモバイルのハードウェアのほとんどで動きません
FragmentShader
precision mediump float;
varying vec3 vPosition;
varying vec3 vNormal;
uniform sampler2D roomTexture;
uniform samplerCube environmentTexture;
uniform vec3 vEyePosition;
uniform vec3 roomTileCount; // 部屋の縦横タイリング数
uniform vec3 roomBoxSize; // 部屋のボックスサイズ
uniform float windowSpecularLevel; // 窓の反射度合い
uniform float z1Position; // z1 の位置を制御 (0.0 ~ 1.0)
uniform float z2Position; // z2 の位置を制御 (0.0 ~ 1.0)
uniform float z3Position; // z3 の位置を制御 (0.0 ~ 1.0)
uniform float exposure; // テクスチャの露光量を制御
const float PI_OVER_2 = asin(1.0); // 90度をラジアンに変換
const float PI = asin(1.0) * 2.0; // 180度をラジアンに変換
// UV座標を回転
vec2 rotateUV(vec2 uv, float angle) {
float cosA = cos(angle);
float sinA = sin(angle);
return vec2(
cosA * (uv.x - 0.5) - sinA * (uv.y - 0.5) + 0.5,
sinA * (uv.x - 0.5) + cosA * (uv.y - 0.5) + 0.5
);
}
// UV座標を反転
vec2 flipUV(vec2 uv, bool flipX, bool flipY) {
uv.x = uv.x * float(!flipX) + (1.0 - uv.x) * float(flipX);
uv.y = uv.y * float(!flipY) + (1.0 - uv.y) * float(flipY);
return uv;
}
// タイルUV計算
vec2 calculateTileUV(vec2 uv, vec2 tilePos, vec2 tileSize) {
return uv * tileSize + tilePos;
}
// 色の露光量を適用する
vec4 applyExposure(vec4 color, float exposure) {
color.rgb *= exposure;
return color;
}
// テクスチャから色をサンプリング
vec4 sampleroomTexture(vec3 pos) {
vec2 uv;
vec2 tileSize = vec2(1.0 / 3.0); // 各タイルのサイズ
vec2 tilePos;
//テクスチャのどのタイルを参照するか、必要に応じて回転や反転して割り当て
if (abs(pos.x) > abs(pos.y) && abs(pos.x) > abs(pos.z)) {
// XR / XL
uv = pos.yz * 0.5 + 0.5;
if (pos.x > 0.0) {
tilePos = vec2(2.0 / 3.0, 1.0 / 3.0); // XR
uv = rotateUV(uv, PI_OVER_2);
} else {
tilePos = vec2(0.0 / 3.0, 1.0 / 3.0); // XL
uv = flipUV(rotateUV(uv, PI_OVER_2), true, false);
}
} else if (abs(pos.y) > abs(pos.x) && abs(pos.y) > abs(pos.z)) {
// YU / YD
uv = pos.xz * 0.5 + 0.5;
if (pos.y > 0.0) {
tilePos = vec2(1.0 / 3.0, 2.0 / 3.0); // YU
uv = flipUV(rotateUV(uv, PI), true, false);
} else {
tilePos = vec2(1.0 / 3.0, 0.0 / 3.0); // YD
}
} else {
// ZB / ZF
uv = pos.xy * 0.5 + 0.5;
if (pos.z > 0.0) {
tilePos = vec2(1.0 / 3.0, 1.0 / 3.0); // ZB
} else {
tilePos = vec2(0.0 / 3.0, 0.0 / 3.0); // ZF
}
}
// タイルUVを計算
uv = calculateTileUV(uv, tilePos, tileSize);
vec4 texColor = texture(roomTexture, uv);
// 露光量を適用
return applyExposure(texColor, exposure);
}
// レイと平面の交差点を計算し、そこから色を取得して透明度を更新
vec4 getPlaneTextureAtHit(float planeDepth, vec3 pos, vec3 rayDir, vec2 tilePos, inout bool processed, inout float transparency) {
if (processed || rayDir.z == 0.0) return vec4(0.0);
float t = (planeDepth - pos.z) / rayDir.z;
if (t <= 0.0) return vec4(0.0);
vec3 hitPoint = pos + t * rayDir;
if (abs(hitPoint.x) > 1.0 || abs(hitPoint.y) > 1.0) return vec4(0.0);
vec2 uv = hitPoint.xy * 0.5 + 0.5;
uv = uv * vec2(1.0 / 3.0) + tilePos;
vec4 texColor = texture(roomTexture, uv);
texColor = applyExposure(texColor, exposure);
vec4 color = texColor * transparency;
transparency *= 1.0 - texColor.a;
processed = true;
return color;
}
vec4 traceRoomRays(vec3 pos, vec3 rayDir, int maxSteps) {
vec4 color = vec4(0.0); // レイキャストによる最終的な色を格納
float transparency = 1.0;
float z1Position = -1.0 + z1Position * 2.0;
float z2Position = -1.0 + z2Position * 2.0;
float z3Position = -1.0 + z3Position * 2.0;
//テクスチャのどのタイルを参照するか
vec2 z1TilePos = vec2(2.0 / 3.0, 2.0 / 3.0);
vec2 z2TilePos = vec2(2.0 / 3.0, 0.0 / 3.0);
vec2 z3TilePos = vec2(0.0, 2.0 / 3.0);
// 各平面がすでに処理されたかを追跡
bool z1Processed = false;
bool z2Processed = false;
bool z3Processed = false;
// レイの逆方向ベクトルを計算(高速化のため事前計算)
vec3 invDir = 1.0 / rayDir;
// レイキャスト処理(最大ステップ数までループ)
for (int i = 0; i < maxSteps; i++) {
vec4 texColor = sampleroomTexture(pos);
// サンプリング結果が透明でなければ色を加算し、透明度を更新
if (texColor.a > 0.0) {
color.rgb += texColor.rgb * texColor.a * transparency; // 色を加算
transparency *= 1.0 - texColor.a; // 透明度を減少
if (transparency < 0.01) break; // 透明ではないと判断したら終了
}
// 各平面との交差点を計算し、色を取得
vec4 z1Color = getPlaneTextureAtHit(z1Position, pos, rayDir, z1TilePos, z1Processed, transparency);
vec4 z2Color = getPlaneTextureAtHit(z2Position, pos, rayDir, z2TilePos, z2Processed, transparency);
vec4 z3Color = getPlaneTextureAtHit(z3Position, pos, rayDir, z3TilePos, z3Processed, transparency);
color.rgb += z1Color.rgb + z2Color.rgb + z3Color.rgb;
if (transparency < 0.01) break;
vec3 delta = (abs(invDir) - pos * invDir);
float step = min(min(delta.x, delta.y), delta.z);
pos += step * rayDir;
}
color.a = 1.0 - transparency;
return color;
}
void main(void) {
// 視点(カメラ位置)から頂点位置への方向を正規化してレイの方向を計算
vec3 rayDir = normalize(vPosition - vEyePosition);
// 平面上のローカル原点を計算(平面の左下を基準に原点を設定)
vec3 planeOrigin = vec3(vPosition.xy + roomBoxSize.xy * 0.5, 0.0);
// 平面サイズを基準にスケールを計算(スケールを正規化するための逆数)
vec3 roomScale = vec3(1.0) / roomBoxSize;
// 原点にスケールを適用して正規化された座標を計算
vec3 scaledOrigin = planeOrigin * roomScale;
// タイル数を基に座標を調整し、テクスチャマッピングの基準を計算
vec3 roomUVW = scaledOrigin * roomTileCount;
// 座標を 0〜1 の範囲に収める(小数部分のみを使用)
roomUVW = fract(roomUVW);
// 座標をローカル空間(-1.0 〜 1.0 の範囲)に変換
vec3 normalizedPosition = roomUVW * 2.0 - 1.0;
// ボックス内部のレイキャストを実行して内部の色を取得
vec4 interiorColor = traceRoomRays(normalizedPosition, rayDir, 10);
// レイの反射方向から環境マッピングの色を取得
vec4 reflectionColor = texture(environmentTexture, reflect(rayDir, vNormal));
// 環境マッピングの色に反射度合いを適用
reflectionColor *= windowSpecularLevel;
// 内部の色と反射の色を合成して最終的な色を計算
gl_FragColor = mix(interiorColor + reflectionColor * 0.5, vec4(0.5, 0.5, 0.5, 1.0), 0.2);
// 最終的な色にアルファ値を適用
gl_FragColor.a = interiorColor.a;
}
roomTextureとそれぞれの割り当て先
sampleRoomTextureで回転だけでなく反転をしている理由
XYZそれぞれ、一方向からテクスチャを投影しているためです。(上記画像のように投影されています。内側から見るとXLは鏡文字になるのがご想像いただけるはずです。)
Zの場合はZF、ZBどちらも同じ方向から見るため反転の必要はありません。
法線の向きで投影すれば回転だけで済むはずですが、うまくいかなかったのと実装できたところで使い勝手が良くなるかが疑問だったため断念しました。
Babylon.js
BABYLON.Effect.ShadersStore['vertexShader'] = `
// VertexShaderのコード
`;
BABYLON.Effect.ShadersStore['fragmentShader'] = `
// FragmentShaderのコード
`;
const shaderMaterial = new BABYLON.ShaderMaterial(
"Fake Interior Shader",
scene,
{
vertex: "custom",
fragment: "custom",
},
{
attributes: ["position", "normal", "uv"],
uniforms: [
"world",
"worldView",
"worldViewProjection",
"view",
"projection",
"vEyePosition", // カメラの位置
"roomBoxSize", // 平面サイズ
"roomTileCount", // 部屋をいくつタイリング配置するか
"z1Position", // Z1 の位置
"z2Position", // Z2 の位置
"z3Position", // Z3 の位置
"windowSpecularLevel", // 窓の反射度合い
"exposure", // ライトの明るさ
],
samplers: [
"roomTexture", // 室内テクスチャ
"environmentTexture", // 環境テクスチャ
],
}
);
//平面オブジェクトの生成とshaderMaterialの割り当て
const roomBoxSize = { width: 1, height: 1, depth: 1 };
const plane = BABYLON.MeshBuilder.CreatePlane("plane", { width: roomBoxSize.width, height: roomBoxSize.height }, scene);
plane.position.y = roomBoxSize.height / 2;
plane.material = shaderMaterial;
scene.registerBeforeRender(() => {
shaderMaterial.setVector3(
"vEyePosition",
new BABYLON.Vector3(
scene.activeCamera.position.x - plane.position.x,
scene.activeCamera.position.y - plane.position.y,
scene.activeCamera.position.z - plane.position.z
)
);
});
// 部屋をいくつタイリング配置するか
shaderMaterial.setVector3("roomTileCount", new BABYLON.Vector3(1, 1, 1));
// 部屋のサイズ
shaderMaterial.setVector3("roomBoxSize", new BABYLON.Vector3(roomBoxSize.width, roomBoxSize.height, roomBoxSize.depth));
// 部屋のテクスチャの設定 FakeInteriorImage.exrは適切なパスに変更してください。
const roomTexture = new BABYLON.Texture(`FakeInteriorImage.exrのパスを記述`,
scene
);
shaderMaterial.setTexture("roomTexture", roomTexture);
// z1,z2,z3 の位置を制御 (0.0 前 ~ 1.0 後)
shaderMaterial.setFloat("z1Position", 0.25);
shaderMaterial.setFloat("z2Position", 0.5);
shaderMaterial.setFloat("z3Position", 0.75);
// ライトの明るさと窓の反射度合いの設定
shaderMaterial.setFloat("exposure", 2);
shaderMaterial.setFloat("windowSpecularLevel", 0.3);
//IBLの設定 IBL-Image.hdrは適切なパスに変更してください。
const environmentTexture = new BABYLON.HDRCubeTexture(`IBL-Image.hdrのパスを記述`, scene, 512, false, true);
scene.environmentTexture = environmentTexture;
shaderMaterial.setTexture("environmentTexture", environmentTexture);
// 地面の生成
BABYLON.MeshBuilder.CreateGround("ground", { width: 6, height: 6 }, scene);
平面オブジェクトのY位置について
//平面オブジェクトの生成
const roomBoxSize = { width: 1, height: 1, depth: 1 };
const plane = BABYLON.MeshBuilder.CreatePlane("plane", { width: roomBoxSize.width, height: roomBoxSize.height }, scene);
plane.position.y = roomBoxSize.height / 2;
// 部屋のサイズ
shaderMaterial.setVector3("roomBoxSize", new BABYLON.Vector3(roomBoxSize.width, roomBoxSize.height, roomBoxSize.depth));
移動させないと下半分が地面に埋もれます。
原点が平面の中央なため、縦サイズの半分移動させるとちょうど地面に接地します。
また、平面オブジェクトのサイズは部屋の縦横サイズと同じ値を使いたいため、roomBoxSize
を参照しています。
roomTextureについて
exr(もしくはhdr)を使っていますが、これはテクスチャがライト情報も兼ねているためです。
HDR画像は一般的なjpgなどのSDR画像と比較して広範囲の輝度情報を持つことが可能で、今回のようにライトの強さを露光量で変化させても白飛び、黒潰れすることなく自然な結果になります。
最後に
非効率、高負荷な点がございましたらご指摘いただけると幸いです。
この技術はゲームMarvel’s Spider-Manで使われているそうです。
他にも映画業界等では簡単に景観を作れて凄く時短になるというメリットがありますが、WEBサイトにおいては室内インテリア画像、3Dで実際に作った場合、それぞれのデータ容量や使いやすさを天秤にかけたときに果たして使い道はあるのだろうかというのが率直な感想です。
謝辞
紹介したシェーダーコードはCedricさんのコードを参考にして作りました。
ありがとうございました。