takumifukasawa’s blog

WebGL, Shader, Unity, UE4

【Unity】Screen Space Ambient Occlusion のカスタムポストプロセスの実装

デモのgitはこちらです。

github.com

環境はこちらです。

Unity 2021.3.26f built-in pipeline

色も調整できるようにしてみています。

現実とリアルタイムグラフィクスの壁

Ambient Occlusion は直訳すると「環境遮蔽」です。

室内に目を向けると、天井と壁の継ぎ目の隅はちょっと暗くなっています。大雑把な理由は「光が届きにくいから」です。しかしこれがリアルタイムグラフィックスだととても厄介なものになります。

レイトレーシングやいわゆるシェーダー芸のレイマーチングはある程度光学的に正しいアプローチをとることができるので再現しやすいのですが、ラスタライズ手法の Forward Rendering, Deferred Rendering では再現に一苦労します。

それは、 Forward Rendering や Deferred Rendering のライティングは、どちらも基本的には「周囲のオブジェクト」は考慮しないものになっているからです。

forward rendering では一個一個のオブジェクトを塗る時に、そのオブジェクトと光源の情報から色を決定させているからです。deferred rendering はポストプロセス的にG-Bufferを用いてライティングを考慮した色を計算していますが、「周囲のオブジェクト」を考慮しないライティング計算になっている点は同じです。

しかし、直接光がもたらす影など陰影はオブジェクト同士の関係性の認識に大いに役立ちます。近距離にあるオブジェクト同士がもたらす影は、距離感の把握やリアリティさの向上につながります。

SSAO (Screen Space Ambient Occlusion)

そこで登場するのが Screen Space Ambient Occlusion、通称SSAOです。文字の通り、スクリーンスペース(ポストプロセス)のアプローチで環境遮蔽を実現する方法です。

利点は動く物体にも適用できる点です。あらかじめBakeしている必要がありません。

欠点は、品質を求めれば求めるほど負荷が高くなりやすい点です。

その歴史はこちらのリンクがとてもわかりやすいです。ここ十数年ぐらいの話なんですね。

https://ambientocclusion.hatenablog.com/entry/2013/10/15/223302

今回は、3種のSSAOの根本的な実装をやってみました。

  1. CryEngine2 の SSAO(全球サンプリング)
  2. StarCraft II の SSAO(半球サンプリング)
  3. UE4 の SSAO(Angle Based)※ Angle Based という名前が一般的かは不明

今回は実装していないのですが、本来は見た目の品質を綺麗にするためにAmbientOcclusionを計算した後にバイラテラルフィルターなどを使ってエッジのぼかしをかける場合が多いようです(Angled Based の場合はなくてもよい?)。ぼかすことによってAmbientOcclusionが効いているように見える範囲を広げることによって遮蔽の見た目をより強調する、という意味もあるかもしれません。

SSAOの原理的な部分を知りたかったため、ぼかし関連の品質向上的な処理は省いています。

ちなみにぼかし処理は基本的に重くなりやすいです。例えば縦横5px幅ずつのガウシアンブラーフィルターはそれだけで 10 * 2 + 1 = 21回テクスチャサンプリングが走ってしまいます。そのため特にスクリーンスペースでぼかしをかける場合は注意が必要です。

また、「環境遮蔽」なので環境光にたいしてのみAO項を考慮するのが本来は正しいのですが、今回は Forward Rendring での実装なので環境光にのみAO項を作用させるのが難しいため、シーンの色と環境遮蔽によってもたらされる陰の色をブレンドするようにしています。

1. CryEngine2 の SSAO(全球サンプリング)

「depth bufferを元に、とある点の周囲にどれぐらい遮蔽するものがあるか」を判断する手法です。

自分はビュー座標系を基準にして実装しました。

  1. depth buffer を元に、これから描画する点 P のビュー座標系における位置を求める
  2. 点 P から、点 P を中心とする全球内のランダムな点 S のビュー座標を計算
  3. 点 S の深度値(Sz)と、カメラから見た S の位置の depth buffer の深度値(Sd)を比較
  4. Sd > Sz なら点 S は遮蔽されているとみなす(ex. 画像の右の点)
  5. 1~4を指定したサンプル数繰り返し、遮蔽率を計算

この方法の利点は、depth buffer さえあれば計算可能な点です。

欠点は、必要なサンプリング数が多くなりやすい(無駄なサンプリングが多くなりやすい)点です。今回のようなシンプルなシーンでは64個ぐらいでもある程度それっぽくなるのですが、複雑な形状のシーンの場合はもっとサンプル数が必要になるでしょう。いずれにしても、スペックの低いモバイル端末だとサンプル数64個でも厳しい可能性があります。

また、わりと全体的に暗くなりがちな点も欠点の一つでしょうか。


以下、該当するc#とシェーダーへのリンクです。

https://github.com/takumifukasawa/UnitySSAOCustomPostProcess/blob/master/SSAO_BuiltinPipeline/Assets/Shaders/SSAO.shader https://github.com/takumifukasawa/UnitySSAOCustomPostProcess/blob/master/SSAO_BuiltinPipeline/Assets/Scripts/SSAO.cs

実装を一部抜粋しながら見ていきます。

とある点のビュー座標系における位置はdepthから復元することができます。

float3 ReconstructViewPositionFromDepth(float2 screenUV, float depth)
{
    float4 clipPos = float4(screenUV * 2.0 - 1.0, depth, 1.0);
    #if UNITY_UV_STARTS_AT_TOP
    clipPos.y = -clipPos.y;
    #endif
    float4 viewPos = mul(_InverseProjectionMatrix, clipPos);
    return viewPos.xyz / viewPos.w;
}

...

float3 viewPosition = ReconstructViewPositionFromDepth(i.texcoord, rawDepth);

サンプル数分のループ内で、「復元したビュー座標系における位置からランダムにずらした点」のクリッピング座標を求めます。

        for (int j = 0; j < SAMPLE_COUNT; j++)
        {
            float4 offset = _SamplingPoints[j];
            offset.w = 0;

            float4 offsetViewPosition = float4(viewPosition, 1.) + offset * _OcclusionSampleLength;
            float4 offsetClipPosition = mul(_ProjectionMatrix, offsetViewPosition);

            #if UNITY_UV_STARTS_AT_TOP
            offsetClipPosition.y = -offsetClipPosition.y;
            #endif

            float2 samplingCoord = (offsetClipPosition.xy / offsetClipPosition.w) * 0.5 + 0.5;
            float samplingRawDepth = SampleRawDepth(samplingCoord);
            float3 samplingViewPosition = ReconstructViewPositionFromDepth(samplingCoord, samplingRawDepth);

            // 現在のviewPositionとoffset済みのviewPositionが一定距離離れていたらor近すぎたら無視
            float dist = distance(samplingViewPosition.xyz, viewPosition.xyz);
            if (dist < _OcclusionMinDistance || _OcclusionMaxDistance < dist)
            {
                continue;
            }

            // 対象の点のdepth値が現在のdepth値よりも小さかったら遮蔽とみなす(= 対象の点が現在の点よりもカメラに近かったら)
            if (samplingViewPosition.z > offsetViewPosition.z)
            {
                occludedCount++;
            }
        }

        float aoRate = (float)occludedCount / (float)divCount;

        // NOTE: 本当は環境光のみにAO項を考慮するのがよいが、forward x post process の場合は全体にかけちゃう
        color.rgb = lerp(
            baseColor,
            _OcclusionColor.rgb,
            aoRate * _OcclusionStrength
        );

sampling points は c# 側で単位球内にランダムに散らした点群をシェーダーに渡したものです。

static Vector4[] GetRandomPointsInUnitSphere()
{
    var points = new List<Vector4>();
    while (points.Count < SAMPLING_POINTS_NUM)
    {
        var p = UnityEngine.Random.insideUnitSphere;
        points.Add(new Vector4(p.x, p.y, p.z, 0));
    }

    return points.ToArray();
}

2. StarCraft II の SSAO(半球サンプリング)

全球サンプリングには無駄な部分があります。それは、法線方向の反対側はジオメトリの内側になっている可能性が高い点です。

そこでサンプリングする点を法線方向を考慮した半球に限定することにより最適化を進めた計算方法になります。

方法は全球サンプリングとほぼ変わりません。サンプルする対象の点が半球内になっただけです。

  1. depth buffer を元に、これから描画する点 P のビュー座標系における位置を求める
  2. 点 P から、法線方向の半球内のランダムな点 S のビュー座標を計算
  3. 点 S の深度値(Sz)と、カメラから見た S の位置の depth buffer の深度値(Sd)を比較
  4. Sd > Sz なら点 S は遮蔽されているとみなす
  5. 1~4を指定したサンプル数繰り返し、遮蔽率を計算

利点はサンプリング回数の無駄が減ったことです。

欠点は法線方向への考慮が必要なので法線情報が格納されたテクスチャが必要になる点です。

Deferred Rendering を使う場合はほとんどの場合で G-Buffer に法線を含めているはずなので「どう用意するか」に関しては特に気にする必要はないのですが Forward Rendering の場合は一工夫必要です。

幸い、Unityには DepthTextureMode で法線が入ったテクスチャを焼くように指定することができます。

camera.depthTextureMode |= DepthTextureMode.DepthNormals;

名前の通り、depthと法線を一つのテクスチャに埋めているようですね。素直に実装すると2パス必要なところ、1パスで2つの情報を入れるようにしてくれているのでこれを使うことにします。

Unity - Scripting API: DepthTextureMode.DepthNormals


以下、該当するc#とシェーダーへのリンクです。

https://github.com/takumifukasawa/UnitySSAOCustomPostProcess/blob/master/SSAO_BuiltinPipeline/Assets/Shaders/SSAOHemisphere.shader https://github.com/takumifukasawa/UnitySSAOCustomPostProcess/blob/master/SSAO_BuiltinPipeline/Assets/Scripts/SSAOHemisphere.cs

実装を一部抜粋しながら見ていきます。

まず、半球内にランダムに散らした点を生成します。法線方向への考慮はシェーダー内で行います。

    static Vector4[] GetRandomPointsInUnitHemisphere()
    {
        var points = new List<Vector4>();
        while (points.Count < SAMPLING_POINTS_NUM)
        {
            var r1 = UnityEngine.Random.Range(0f, 1f);
            var r2 = UnityEngine.Random.Range(0f, 1f);
            var x = Mathf.Cos(2 * Mathf.PI * r1) * 2 * Mathf.Sqrt(r2 * (1 - r2));
            var y = Mathf.Sin(2 * Mathf.PI * r1) * 2 * Mathf.Sqrt(r2 * (1 - r2));
            var z = 1 - 2 * r2;
            z = Mathf.Abs(z);
            points.Add(new Vector4(x, y, z, 0));
        }

        return points.ToArray();
    }

法線方向の半球内にランダムにオフセットするために、法線を使った正規直交基底内を利用します。法線マップを実装するときの考え方に近いですね。

法線は _CameraDepthNormalsTexture から取得します。

オフセットする位置を計算したらあとは全球サンプリングと同じ方法になります。

    TEXTURE2D_SAMPLER2D(_CameraDepthNormalsTexture, sampler_CameraDepthNormalsTexture);

...

    float3 SampleViewNormal(float2 uv)
    {
        float4 cdn = SAMPLE_TEXTURE2D(_CameraDepthNormalsTexture, sampler_CameraDepthNormalsTexture, uv);
        return DecodeViewNormalStereo(cdn) * float3(1., 1., 1.);
    }

...

    float3x3 GetTBNMatrix(float3 viewNormal)
    {
        float3 tangent = float3(1, 0, 0);
        float3 bitangent = float3(0, 1, 0);
        float3 normal = viewNormal;
        float3x3 tbn = float3x3(tangent, bitangent, normal);
        return tbn;
    }

...

    float3 viewNormal = SampleViewNormal(i.texcoord);

...

    for (int j = 0; j < SAMPLE_COUNT; j++)
    {
        float3 offset = _SamplingPoints[j];
        offset.z = saturate(offset.z + _OcclusionBias);
        offset = mul(GetTBNMatrix(viewNormal), offset);

...

3. UE4 の SSAO(Angle Based)

SIGGRAPH2012でUE4のデモに関する発表の中で紹介された手法です。

「とある点Pから等距離に伸ばした2点」の位置を計算し、「とある点Pと2点のそれぞれの角度の合計」で遮蔽具合を判断する、という方法です。サンプリング数は6x2で12点を必要としているようです。

つまり、「角度6種と長さ6種の設定」が鍵になります。これをいい具合にばらけさせるなど調整する必要があります。

この方法の利点は、AmbientOcclusionの計算に使うテクスチャのサンプル数が最低12回で済むという点です。全球を考慮した方法と比べると大きな差ですね。また、角度を遮蔽度合いとして捉えることができます。つまり、全球/半球サンプリングでは各サンプリング点において「遮蔽されているかどうか」しか判別できなかったのが、「角度の累積でどれぐらい遮蔽されているかの度合い」を考えることができるのでより近い近似になりそうです。また、サンプリング位置をできるだけ散らすために4x4pxの範囲内でさらに回転を加えているようですね。

サンプル数が「最低12回」と書いたのは、ピクセルベースの法線情報(ex. G-Bufferの法線情報やノーマルマップ)を考慮するかどうかでサンプル数が変わるからです。スライドによると法線を考慮したいケースでは法線情報をもとに範囲を限定しつつさらにもう一回遮蔽度合いの計算を行うようです。そのため、サンプル数は計24回になりますね。


以下、該当するc#とシェーダーへのリンクです。

https://github.com/takumifukasawa/UnitySSAOCustomPostProcess/blob/master/SSAO_BuiltinPipeline/Assets/Shaders/SSAOAngleBased.shader https://github.com/takumifukasawa/UnitySSAOCustomPostProcess/blob/master/SSAO_BuiltinPipeline/Assets/Scripts/SSAOAngleBased.cs

実装を一部抜粋しながら見ていきます。

まず、c#でサンプリングする角度と距離を6つずつ作成します。

スライドでは角度と長さの情報をテクスチャで渡しているのか、Uniformな配列情報として渡しているのかがわかりませんでした。デモではUniformな配列情報として渡すことにします。

ランタイム実行の度に生成しているのですが、毎回良質なサンプリング点を生成できるとは限らないので、あらかじめ任意の点を設定できるようにする方が実際はよいと思います。

            var rotList = new List<float>();
            var lenList = new List<float>();
            var sampleCount = 6;

            for (int i = 0; i < sampleCount; i++)
            {
                // 任意の角度. できるだけ均等にバラけていた方がよい
                var pieceRad = (Mathf.PI * 2) / sampleCount;
                var rad = UnityEngine.Random.Range(
                    pieceRad * i,
                    pieceRad * (i + 1)
                );
                rotList.Add(rad);

                // 任意の長さの範囲. できるだけ均等にバラけていた方がよい
                var baseLen = 0.1f;
                var pieceLen = (1f - baseLen) / sampleCount;
                var len = UnityEngine.Random.Range(
                    baseLen + pieceLen * i,
                    baseLen + pieceLen * (i + 1)
                );
                lenList.Add(len);
            }

            sheet.properties.SetFloatArray("_SamplingRotations", rotList.ToArray());
            sheet.properties.SetFloatArray("_SamplingDistances", lenList.ToArray());

「とある点Pから等距離に伸ばした2点」の位置を計算し、「とある点Pと2点のそれぞれの角度の合計」で遮蔽具合を判断する計算はこちらになります。

スライドやいろいろな記事を見ると2次元的に角度を計算しているようにみえる(おそらくxyのどちらかの次元を落としている)のですが、自分の実装ではビュー座標系において3次元的に角度を計算しています。

        float occludedAcc = 0.;
        int samplingCount = 6;

        for (int j = 0; j < samplingCount; j++)
        {
            float2x2 rot = GetRotationMatrix(_SamplingRotations[j]);
            float offsetLen = _SamplingDistances[j] * _OcclusionSampleLength;
            float3 offsetA = float3(mul(rot, float2(1, 0)), 0.) * offsetLen;
            float3 offsetB = -offsetA;

            float rawDepthA = SampleRawDepthByViewPosition(viewPosition, offsetA);
            float rawDepthB = SampleRawDepthByViewPosition(viewPosition, offsetB);

            float depthA = Linear01Depth(rawDepthA);
            float depthB = Linear01Depth(rawDepthB);

            float3 viewPositionA = ReconstructViewPositionFromDepth(i.texcoord, rawDepthA);
            float3 viewPositionB = ReconstructViewPositionFromDepth(i.texcoord, rawDepthB);

            float distA = distance(viewPositionA, viewPosition);
            float distB = distance(viewPositionB, viewPosition);

            if (abs(depth - depthA) < _OcclusionBias)
            {
                continue;
            }
            if (abs(depth - depthB) < _OcclusionBias)
            {
                continue;
            }

            if (distA < _OcclusionMinDistance || _OcclusionMaxDistance < distA)
            {
                continue;
            }
            if (distB < _OcclusionMinDistance || _OcclusionMaxDistance < distB)
            {
                continue;
            }

            float3 surfaceToCameraDir = -normalize(viewPosition);
            float dotA = dot(normalize(viewPositionA - viewPosition), surfaceToCameraDir);
            float dotB = dot(normalize(viewPositionB - viewPosition), surfaceToCameraDir);
            float ao = (dotA + dotB) * .5;

            occludedAcc += ao;
        }

        float aoRate = occludedAcc / (float)samplingCount;

今回、遮蔽の度合いはこのように -1 ~ 1 の範囲と捉えて計算しています。プロジェクトごとに調整してよい部分かなと思います。例えば「ちょっとでも角度があったら遮蔽しているとみなしたい」時は 0 ~ 1 の範囲の方が適切です。

            float ao = (dotA + dotB) * .5;

            occludedAcc += ao;

品質を高めたい場合はサンプリング回数を増やしてもよいと思います。作っているものによっては描画処理に余裕がある場合などですね。

実装の改善として、角度と長さはfloatの配列でそれぞれ要素数6になっていますがvector2な配列で[0]に角度, [1]に長さを入れ vector2の要素数6の配列にすると送る配列が一つ減るので節約になりそうですね。

github.com

参考

https://zenn.dev/mebiusbox/articles/c7ea4871698ada

https://ambientocclusion.hatenablog.com/entry/2013/11/07/152755

https://de45xmedrsdbp.cloudfront.net/Resources/files/The_Technology_Behind_the_Elemental_Demo_16x9-1248544805.pdf

https://marina.sys.wakayama-u.ac.jp/~tokoi/?date=20101122

https://developers.wonderpla.net/entry/2014/01/31/151540

https://inzkyk.xyz/ray_tracing_in_one_weekend/week_3/3_7_generating_random_directions/

【WebGL】GLTFのUV座標系の向きについて

GLTFのUV座標系について知識不足だったので、メモを残しておきたいと思います。


結論、GLTF自体のフォーマットでは以下の図のようにUV座標は左上が原点(左上が(0,0)で右下が(1,1))に設定されています。

※画像は下記URLの図をスクショしたもの

glTF™ 2.0 Specification

普段html,jsを触っている感覚からすると自然な感覚かなと思います。


しかし、「あえてWebGLのUVの原点が左下になるように設計・実装している」場合には問題が発生します。具体的には、gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); を呼んでいるときです。

まず、画像データを何かしらの方法で読み込む(fetchなど)と座標系の原点は左上になります。 一方、Blenderなどの各種DCCツールでは以下のように UVの座標系の原点を左下 にしているケースが大半だと思います。つまり、Yが反転した状態になっているのです。

(0, 1) ---- (1, 1)
   |           |
   |           |
(0, 0) ---- (1, 0)

Blender の UVエディタウインドウのスクショ


しかし、WebGLでは gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); を呼ぶことによって左下を原点とすることができます。

個人的には各種DCCツールに合わせて左下に原点を持ってきた方がわかりやすいと思っていてこの UNPACK... の指定は入れるようにしているのですが、GLTFをパースしているときに「UVのY軸がなぜか逆だな」と思って調べてみると、そもそもGLTFでは左上が原点になっていた、と気がついた経緯でした。

最終的には以下のような実装にまとまりました。

  • 左下を原点とするために gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); は指定する
  • GLTFからUVを読み込む際、Yを反転させてからBufferにデータを送る

【WebGL】CubeMap(環境マップ)の軸について整理する

生のWebGLを書いているときにCubeMap(環境マップ)がサンプルされる軸についてややこしくなったので整理したいと思います。

具体的には、WebGL2においてCubeMapの色を取得するために texture(samplerCube, vec3) で呼び出す際、 「どうサンプルされるのが正しいのか」「渡したベクトルに対してCubeMapのどの軸がサンプルされるか?」というような内容になります。

skyboxも実装したサンプル図です(skyboxそのものの実装については割愛します)

こちらは開発中のWebGLの自作ライブラリになるのですが動作デモになります。

https://takumifukasawa.github.io/PaleGL/workspace/011_cube-mapping/

目次

CubeMapの考え方

この画像のように、シェーダー内ではカメラから物体の表面に対して、法線を軸に反射させたベクトルを使うのが基本的な考え方です。

映り込み(specular)に使うと、画像から色をサンプルしているかつ視点によってサンプルする色も変化するので情報量が増します。(ex. UnityのReflectionProbeや、Skyboxのreflection成分)

WebGLAPIも踏まえた具体的な説明はこちらがとてもわかりやすいです。

wgld.org | WebGL: キューブ環境マッピング |

サンプルするベクトルを変えながら確認

以下のような状態で、球体を置いてCubeMappingを実装していきます。

  • 球体に出力する色はCubeMappingからサンプルした色をそのまま出す
  • js側(各種行列など)は右手座標系で実装。z+が手前
  • 球体の位置は(0,0,0)。カメラの位置は(0,0,5)で球体を向く。つまり-zを向く状態。
  • CubeMapに使う6面の画像は+x,-x,+y,-y,+z,-zが分かるような画像

1. reflectしたものをそのまま使う

// PtoE ... 描画する点のワールド座標 -> カメラのワールド座標 の正規化ベクトル
// N ... 正規化した法線
vec3 reflectDir = reflect(-PtoE, N);
vec3 cubeColor = texture(uCubeTexture, reflectDir).xyz;

どうやら軸は手前がz+になっているようです。しかし、「反転して鏡のように映る」期待は逆の結果になります。

CubeMapの軸

このサイトによると、CubeMapの参照する軸についてこう書かれています。

Pragmatic physically based rendering : HDR

Cubemaps spec comes from the time when RenderMap ruled the world and Renderman it's using Left-Handed Coordinate system so do the cubemaps. That means to we will need to flip the X axis in our shader whenever we sample from a CubeMap texture. Additionally you will need to point your camera towards +Z instead of the usual -Z in order to start at expected direction. Otherwise you might end up looking at the wall like in case of the Pisa texture we are using.

どうやら、CubeMapはRenderMap(Autodesk製のソフト?ツール?)にならって左手座標系を基準としているようです。

しかしWebGLは右手座標系で設計されている(ポリゴンの正面判定など)ので、参照する軸が異なります。

つまりCubeMapの考え方に沿って左手座標系に沿って実装するためには、まずx軸を反転する必要があり、場合によってはz軸も反転する必要がある、ということのようです。

(↑ 解釈が間違っていたらすいません)

試しにx軸を反転させてみます。

2. reflect & x軸を反転

// PtoE ... 描画する点のワールド座標 -> カメラのワールド座標 の正規化ベクトル
// N ... 正規化した法線
vec3 reflectDir = reflect(-PtoE, N);
reflectDir.x *= -1.;
vec3 cubeColor = texture(uCubeTexture, reflectDir).xyz;

z+が手前、x+が左側になりました。両方反転させると左手座標系になるので、180度回転をさせます。

3. reflect & x軸を反転 & xzを180度回転

// PtoE ... 描画する点のワールド座標 -> カメラのワールド座標 の正規化ベクトル
// N ... 正規化した法線

mat2 rotate(float r) {
    float c = cos(r);
    float s = sin(r);
    return mat2(c, s, -s, c);
}

vec3 reflectDir = reflect(-PtoE, N);
reflectDir.x *= -1.;
reflectDir.xz *= rotate(3.14);
vec3 cubeColor = texture(uCubeTexture, reflectDir).xyz;

手前がz-、右がz+となっています。また、それぞれ反転しているので無事に左手座標系に直りました。

参考

Pragmatic physically based rendering : HDR

WebGLでキューブマッピング - Qiita

wgld.org | WebGL: キューブ環境マッピング |

0~1のfloatを32bitRGBAに格納する

デモはこちらにおきました。

デモ https://takumifukasawa.github.io/float-to-rgba-tester/

リポジトリ GitHub - takumifukasawa/float-to-rgba-tester: float to rgba tester


こちらが計算部分のjavascriptのソースになります。

class FloatPacker {
    static packToRGBA(num) {
        const rawR = num * 255;
        const r = Math.floor(rawR);
        const rawG = (rawR - r) * 255;
        const g = Math.floor(rawG);
        const rawB = (rawG - g) * 255;
        const b = Math.floor(rawB);
        const rawA = (rawB - b) * 255;
        const a = Math.floor(rawA);
        return { r, g, b, a };
    }
    static unpackToFloat({ r, g, b, a }) {
        return r / 255 + g / (255 * 255) + b / (255 * 255 * 255) + a / (255 * 255 * 255 * 255);
    }
}

// usage example
const { r, g, b, a } = FloatPackage.packToRGBA(0.87185264);
const unpackedFloat = FloatPackage.unpackToFloat({ r, g, b, a });

概要

jpgやpngなど、普段使うことの多いテクスチャの形式ではRGBAの各チャンネルの表現できる値は8bitなので0~255までの256段階になります。

そのため、VATなど頂点シェーダーであらかじめ用意されたデータをテクスチャから読み込んで取り扱うときは、0~255の256段階でしか使うことができません。

しかし、頂点シェーダーでは0.5145や5.4514など、floatな値を使いたい場面があり、256段階では精度が足りないことがほとんどです。

浮動小数点テクスチャを扱うことができればその限りではないのですが、jpgやpngなどの 8bit x 4 = 32bit/pixel なテクスチャの場合は、浮動小数点的をそのままチャンネルに詰めることができません。チャンネルごとに32bitであればもちろんそのまま浮動小数点を入れることができるのですが、8bitだと浮動小数点を入れるにはbit数が足らないからですね。

そのため、浮動小数点をとある変換式にかけることでRGBAの 8bit x 4 の形式に変換し、多少誤差があるものの 8bit x 4 = 32bit/pixel から浮動小数点に直す方法をとることで、浮動小数点を 8bit x 4 = 32bit/pixel な形式のテクスチャに格納することができます。

ただし、0~1の間であることが条件になります。

変換

まず、0.87185264という値があるとします。

この数値に 255 をかけて、整数部分のみ切り出します。 これがRチャンネルに入る要素になります。

0.87185264 * 255 = 222.3224232 => 222

次に、先ほどの小数部分のみを切り出し、再度255をかけて整数部分のみをきりだします。 これがGチャンネルに入る要素になります。

0.3224232 * 255 = 82.217916 => 82

これを残りのB,Aチャンネル分の2回繰り返します。

0.217916 * 255 = 55.56858 => 55
0.56858 * 255 = 144.9879 => 144

最終的に R=222, G=82, B=55, A=144 という数値が得られました。

それでは、0~1の浮動小数点に復元してみましょう これまでは、255をかけて整数部分を切り出すことをしていたので、この逆の計算をしていきます。

(222 / 255) + (82 / (255 ^ 2)) + (55 / (255 ^ 3)) + (144 / (255 ^ 4)) = 0.8718526397663572

最終的に、差分は 0.8718526397663572 - 0.87185264 = -2.336428e-10 となりました。

この程度の差であれば、場面によってはほぼ誤差に近いと捉えることもできそうです。


参考

www.gamedev.net

【Rider】Windowsで.ideavimrcが読み込まれないとき

おそらく特殊な状況だったと思うのですが、メモしておきます。

経緯としては、IdeaVimのアラート音を消したいためにideavimrcを編集していたのですが反映されない現象に出くわしていました。


RiderでVimキーバインドを使う場合、IdeaVimを使うことになります。 設定は~/.ideavimrcに書くことで適用されます。

Windowsの場合は C:Users/{username}/.ideavimrc に置くことになります。 しかし、.ideavimrc に設定を書いても読み込まれることはありませんでした。

よくよく C:Users/{username}/ を見てみると、.vimrcが存在していませんでした。

そこで、空の.vimrcを作成し.ideavimrcから読み込むようにすることで、設定が反映されました。

ファイルの中身は以下のようになりました。


.ideavimrc

source ~/.vimrc
set visualbell
set noerrorbells

.vimrc は空

WebWorkerではプライベートフィールドを渡すことができない

ちょっとハマったのでメモです。

WebWorkerのpostMessageでデータを送るとき、渡すことのできるデータとそうでないデータがあります。

これは構造化複製アルゴリズムが適用されているからです。たとえばFunctionオブジェクトは送ることができません。

developer.mozilla.org

その中にプライベートフィールドも含まれていました。

chromeで以下のコードを実行すると Object で { hoge: "hogehoge" } が出力されます。

class Hoge {
  hoge;
  #fuga;
  constructor(hoge, fuga) {
    this.hoge = hoge;
    this.#fuga = fuga;
  }
  echo() {
    console.log(this.hoge);
    console.log(this.#fuga);
  }
}

const hoge = new Hoge("hogehoge", "fugafuga");

const worker = new Worker(URL.createObjectURL(new Blob([`
self.onmessage = (e) => {
  console.log(e.data[0]); // Object { hoge: "hogehoge" }
}
`])));

worker.postMessage([hoge]);

【Photoshop】レイヤーモードのオーバーレイの原理

Photoshopのレイヤーモードには、加算、乗算など様々な種類があります。

下のレイヤーに対して上のレイヤーの色をどう重ねるかの方式の設定になります。

WebGLで言うところのBlendModeに近い処理ですね。

例えば加算を設定した結果どういう色になるかは、感覚的にも、BlendModeと比較した場合にもイメージがつきやすいのですが、オーバーレイに関してはどういうことをやっているのか良くわからなかったので、計算方法を調べてみました。

計算式

おそらく、各レイヤーモードの計算式はこちらにまとまっているものになるかと思います。

stackoverflow.com

stack overflow の記事に載っている計算式を一部抜粋します。

#define ChannelBlend_Overlay(A,B)    ((uint8)((B < 128) ? (2 * A * B / 255):(255 - 2 * (255 - A) * (255 - B) / 255)))
...
#define ChannelBlend_Multiply(A,B)   ((uint8)((A * B) / 255))
#define ChannelBlend_Screen(A,B)     ((uint8)(255 - (((255 - A) * (255 - B)) >> 8)))

どうやら、下の色が128以上の場合はスクリーンを、128未満の場合は乗算を適用しているのがオーバーレイであるようです。

スクリーンの計算方法については今回は言及せず、オーバーレイについてのみ焦点を当てます。


オーバーレイを使った時の見た目のサンプルと、計算結果のサンプルを2パターン作ってみたので、それぞれ記載していきます。

計算は、オーバーレイの式をjavascript向けに編集したものを使いました。

// Aが下のレイヤーのピクセル色、Bが上のレイヤーのピクセル色(オーバーレイで重ねる色)
const overlay = (A, B) =>
  ((A < 128) ?
  (2 * B * A / 255) :
  (255 - 2 * (255 - B) * (255 - A) / 255));

1. 白黒のグラデにべた塗のレイヤーをオーバーレイで重ねる

中央の矩形が重なっている部分です。

f:id:takumifukasawa:20210926120503j:plain

// 白黒のグラデに真っ白のべた塗レイヤーをオーバーレイで重ねる(図上)

overlay(255, 255);
// 255
overlay(192, 255);
// 255
overlay(128, 255);
// 255
overlay(64, 255);
// 128
overlay(0, 255);
// 0

// 白黒のグラデのレイヤーに真っ黒のべた塗をオーバーレイで重ねる(図下)

overlay(255, 0);
// 255
overlay(192, 0);
// 129
overlay(128, 0);
// 1
overlay(64, 0);
// 0
overlay(0, 0);
// 0

傾向として、

  • 真っ白をオーバーレイで重ねる場合、下のピクセル色が、

    • 128以上: 255になる
    • 128未満: 0~128の範囲が0~255に変換される
  • 真っ黒をオーバーレイで重ねる場合、下のピクセル色が、

    • 128以上: 128~255の範囲が0~255に変換される
    • 128未満: 0になる

ということが分かりました。

見た目的には、白いレイヤーをオーバーレイで重ねると明るい部分の幅が広がり、黒いレイヤーをオーバーレイで重ねると暗い部分の幅が広がっていますね。

ざっくり書くと、全体をより明るく、より暗くする処理の出し分け、としてオーバーレイを使うことになるのかなと思いました。

(例えば、コントラストを部分的に強く、彩度を強くする使い方など?)

2. べた塗のレイヤーの上に白黒のグラデをオーバーレイで重ねる

f:id:takumifukasawa:20210926120514j:plain

// 真っ白のべた塗レイヤーに白黒のグラデをオーバーレイで重ねる(図上)

overlay(255, 255);
// 255
overlay(255, 192);
// 255
overlay(255, 128);
// 255
overlay(255, 64);
// 255
overlay(255, 0);
// 255

// 真っ黒のべた塗レイヤーに白黒のグラデをオーバーレイで重ねる(図下)

overlay(0, 255);
// 0
overlay(0, 192);
// 0
overlay(0, 128);
// 0
overlay(0, 64);
// 0
overlay(0, 0);
// 0

結果を見ると、オーバーレイで重ねる色に関わらず、下のピクセル色が0,255になっている場合はそのままの色(真っ黒か真っ白)になっています。

前述のように、「より明るく」「より暗く」することが目的のレイヤーモードだとすると、重ねる元の色によっては全く変わらない見た目になる場合があることは正しいと言えそうです。