凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity でボリュームレンダリングをしてみる - vol.1 データ表示

はじめに

今回はボリュームレンダリングについて調べてみました。

ボリュームレンダリングは CT スキャンや MRI などで得られたデータや、雲やエフェクトなどの事前に用意またはリアルタイムに計算した 3 次元のデータを 2 次元の画面上に描画するための手法です。Unity では 3D のデータとしてTexture3Dが用意されており、これをシェーダを通じて処理することで GPU を使った計算によるレンダリングが行えます。本エントリではまずは入門編ということで、簡単なレイキャストによるボリュームレンダリングを紹介します。

プロジェクト

今回のプロジェクトのサンプルは以下になります。

github.com

1 ~ 3 が今回の範囲になります。

Texture3D

まずは Texture3D を使うためのおさらいです。Texture3D については以下で少し触れました。

tips.hecomi.com

おなじコードを使って適当な Texture3D を生成してみましょう。

using UnityEngine;

public class Create3DTex : MonoBehaviour
{
    [SerializeField]
    int size = 16;

    void Start()
    {
        var tex = new Texture3D(size, size, size, TextureFormat.ARGB32, true);
        var colors = new Color[size * size * size];

        float a = 1f / (size - 1);
        int i = 0;
        Color c = Color.white;

        for (int z = 0; z < size; ++z)
        {
            for (int y = 0; y < size; ++y)
            {
                for (int x = 0; x < size; ++x, ++i)
                {
                    c.r = ((x & 1) != 0) ? x * a : 1 - x * a;
                    c.g = ((y & 1) != 0) ? y * a : 1 - y * a;
                    c.b = ((z & 1) != 0) ? z * a : 1 - z * a;
                    colors[i] = c;
                }
            }
        }

        tex.SetPixels(colors);
        tex.Apply();

        var renderer = GetComponent<Renderer>();
        renderer.material.SetTexture("_Volume", tex);
    }
}

これにより作成された Texture3D をシェーダ内で tex3D を使って参照します。

Shader "VolumeRendering/3D Texture"
{

Properties
{
    _Volume("Volume", 3D) = "" {}
}

CGINCLUDE

#include "UnityCG.cginc"

struct appdata
{
    float4 vertex : POSITION;
};

struct v2f
{
    float4 vertex : SV_POSITION;
    float3 uv : TEXCOORD0;
};

sampler3D _Volume;

v2f vert(appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uv = v.vertex.xyz * 0.5 + 0.5;
    return o;
}

fixed4 frag(v2f i) : SV_Target
{
    return tex3D(_Volume, i.uv);
}

ENDCG

SubShader
{

Tags 
{ 
    "RenderType"="Opaque" 
}

Pass
{
    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    ENDCG
}

}

}

f:id:hecomi:20180103132714p:plain

ポリゴン表面に来る 3D テクスチャの色が描かれます。次のように頂点シェーダのコードを変更すれば図形の大きさを変更したり動かしたりすることで、見たい 3D テクスチャの部位をワールド座標でクリッピング出来ます。

v2f vert(appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    float4 wpos = mul(unity_ObjectToWorld, v.vertex);
    o.uv = wpos.xyz * 0.5 + 0.5;
    return o;
}

f:id:hecomi:20180103135236g:plain

3D テクスチャのデータ

データのダウンロードと変換

コードで適当に生成したものでは面白くないので、実際にスキャンされたデータを使ってみましょう。以下のサイトで、PVM という形式のファイルが非商用・無保証で配布されています。

試しに鯉(Carp)のデータ(Carp.pvm)をダウンロードしてデータを見てみます。PVM そのままでは読めないので、RAW データに変換するために以下のサイトで配布されている V3(Versatile Volume Viewer)をビルドして、中に含まれている pvm2raw というツールを使います。

www.stereofx.org

sourceforge.net

Mac だとビルドスクリプトが動かなかったので、CMake でビルドしました。

$ unzip VIEWER-5.2.zip
$ cd viewer
$ cmake .
$ make

tools ディレクトリに pvm2raw が入っているので、これで先ほどダウンロードした Carp.pvm を変換します。

$ viewer/tools/pvm2raw Carp.pvm Carp.raw
reading PVM file
found volume with width=256 height=256 depth=512 components=2
and edge length 0.78125/0.390625/1
and data checksum=AEB61B35

これで RAW のデータができました。RAW のデータの中には width * height * depth * components byte のデータが含まれています。Carp のデータは 256 * 256 * 512 * 16 bit なので合計 67108864 byte になります。

データの読み込み

この RAW データを Unity の Texture3D に変換してみます。折角なのでお勉強がてら ScriptedImporterDnD で変換できるようにします。

tsubakit1.hateblo.jp

docs.unity3d.com

ちょっとコードが長くてアレですが...、以下のようなコードを書くと、RAW データをそのまま 3D テクスチャとして使えるようになります。

using UnityEngine;
using UnityEditor;
using UnityEditor.Experimental.AssetImporters;
using System;
using System.IO;

[ScriptedImporter(1, "raw")]
public class PvmRawImporter : ScriptedImporter
{
    public enum Bits
    {
        Eight = 1,
        Sixteen = 2,
    }

    public int width = 256;
    public int height = 256;
    public int depth = 256;
    public Bits bit = Bits.Eight;

    int totalSize
    {
        get { return width * height * depth * (int)bit; }
    }

    int maxValueSize
    {
        get 
        {
            switch (bit)
            {
                case Bits.Eight   : return (int)Byte.MaxValue;
                case Bits.Sixteen : return (int)UInt16.MaxValue;
                default:
                    throw new Exception("bit is wrong.");
            }
        }
    }

    Texture3D GetTexture3D(string path)
    {
        using (var stream = new FileStream(path, FileMode.Open))
        {
            if (stream.Length != totalSize) 
            { 
                throw new Exception("Data size is wrong."); 
            }

            int n = totalSize;
            var colors = new Color[n];
            float a = 1f / maxValueSize;
            var buf = new byte[(int)bit];

            for (int i = 0; i < n; ++i)
            {
                float value = 0f;
                switch (bit)
                {
                    case Bits.Eight:
                        var b = stream.ReadByte();
                        value = a * b;
                        break;
                    case Bits.Sixteen:
                        stream.Read(buf, 0, 2);
                        value = a * BitConverter.ToUInt16(buf, 0);
                        break;
                }
                colors[i] = new Color(value, value, value, value);
            }

            var tex3d = new Texture3D(width, height, depth, TextureFormat.RGBA32, false);
            tex3d.SetPixels(colors, 0);

            return tex3d;
        }
    }

    public override void OnImportAsset(AssetImportContext ctx)
    {
        try
        {
            var tex3d = GetTexture3D(ctx.assetPath);
            ctx.AddObjectToAsset("Volume", tex3d);
        }
        catch (Exception e)
        {
            Debug.LogException(e);
        }
    }
}

RAW データをドラッグ&ドロップすると次の図のように public 変数がインスペクタに表示されます。enum がプルダウンにならないのがアレですが...、鯉のデータは 256 * 256 * 512、16 bit なので、そうなるよう入力し、Apply ボタンを押します。

f:id:hecomi:20180103214239p:plain

f:id:hecomi:20180103214257p:plain

これで RAW データが Texture3D として使えるようになりました。

データを見てみる

これを先ほどと同じようにクリッピングしてみると次のようになります。

f:id:hecomi:20180103225929g:plain

見ての通り、単にワールド座標で区切っただけだと全体がどうなっているのか分かりませんね。次にこれをボリュームレンダリングによって見えるようにしましょう。

ボリュームレンダリング

実装

手法はいくつかあるようなのですが、以下のサイトの方法を参考にします。

graphicsrunner.blogspot.jp

ここで使われている仕組みはそこまで難しくなく、直方体で区切られたエリアをちょっとずつレイを進めていき、その点の 3D テクスチャをサンプリングして足し合わせてアルファブレンディングしていく方式です。以下に図と実装を示します。ちょっと方式は変えて、レイを進めるのはローカル(オブジェクト)スペースで計算するようにしています。

f:id:hecomi:20180104172014p:plain

Shader "VolumeRendering/VolumeRendering"
{

Properties
{
    _Volume("Volume", 3D) = "" {}
    _Color("Color", Color) = (1, 1, 1, 1)
    _Iteration("Iteration", Int) = 10
    _Intensity("Intensity", Range(0, 1)) = 0.1
}

CGINCLUDE

#include "UnityCG.cginc"

struct appdata
{
    float4 vertex : POSITION;
};

struct v2f
{
    float4 vertex   : SV_POSITION;
    float4 localPos : TEXCOORD0;
    float4 worldPos : TEXCOORD1;
};

sampler3D _Volume;
fixed4 _Color;
int _Iteration;
fixed _Intensity;

v2f vert(appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.localPos = v.vertex;
    o.worldPos = mul(unity_ObjectToWorld, v.vertex);
    return o;
}

fixed4 frag(v2f i) : SV_Target
{
    float3 wdir = i.worldPos - _WorldSpaceCameraPos;
    float3 ldir = normalize(mul(unity_WorldToObject, wdir));
    float3 lstep = ldir / _Iteration;
    float3 lpos = i.localPos;
    fixed output = 0.0;

    [loop]
    for (int i = 0; i < _Iteration; ++i)
    {
        fixed a = tex3D(_Volume, lpos + 0.5).r;
        output += (1 - output) * a * _Intensity;
        lpos += lstep;
        if (!all(max(0.5 - abs(lpos), 0.0))) break;
    }

    return _Color * output;
}

ENDCG

SubShader
{

Tags 
{ 
    "Queue" = "Transparent"
    "RenderType" = "Transparent" 
}

Pass
{
    Cull Back
    ZWrite Off
    Blend SrcAlpha OneMinusSrcAlpha 
    Lighting Off

    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    ENDCG
}

}

}

オブジェクトの内外判定(ループ内の if 文)はこちらの記事と同じ方式です。

tips.hecomi.com

内外判定は Masatatsu Nakamura さんの以下のリポジトリでやっているように AABB と直線の判定にしたほうが軽量で且つ事前にレイのステップも適切なものを選べることから、次回はこちらでやります。

github.com

では結果を見てみましょう。

f:id:hecomi:20180104173637g:plain

これで全体像が見えるようになります。ループ回数(_Iteration)を増やすと重くはなりますが鮮明になります。

f:id:hecomi:20180104174117p:plain

切ってみる

スライスできるようにシェーダに範囲のプロパティを追加してみましょう。

Shader "VolumeRendering/VolumeRendering"
{

Properties
{
    [Header(Rendering)]
    _Volume("Volume", 3D) = "" {}
    ...

    [Header(Ranges)]
    _MinX("MinX", Range(0, 1)) = 0.0
    _MaxX("MaxX", Range(0, 1)) = 1.0
    _MinY("MinY", Range(0, 1)) = 0.0
    _MaxY("MaxY", Range(0, 1)) = 1.0
    _MinZ("MinZ", Range(0, 1)) = 0.0
    _MaxZ("MaxZ", Range(0, 1)) = 1.0
}

...

fixed _MinX, _MaxX, _MinY, _MaxY, _MinZ, _MaxZ;

fixed sample(float3 pos)
{
    fixed x = step(pos.x, _MaxX) * step(_MinX, pos.x);
    fixed y = step(pos.y, _MaxY) * step(_MinY, pos.y);
    fixed z = step(pos.z, _MaxZ) * step(_MinZ, pos.z);
    return tex3D(_Volume, pos).r * x * y * z;
}

...

fixed4 frag(v2f i) : SV_Target
{
    ...
    for (int i = 0; i < _Iteration; ++i)
    {
        fixed a = sample(lpos + 0.5);
        ...
    }
    ...
}

...

}

f:id:hecomi:20180105142610g:plain

f:id:hecomi:20180105143119p:plain

鯉こくとか三枚おろしとか作れそうです。

おわりに

次回は今回雑に実装をしたところの改善や、色付け、シェーディングしたりするところをやります。

追記(2018/01/08)

続きを書きました。

tips.hecomi.com