动机

最近看到了三角洲行动介绍的在虚幻引擎4中实现的地形渲染方案,感觉受益匪浅。不过在Unity中要想实现一个即插即用的虚拟贴图的技术应该有些困难,于是我把目光放在了最一开始所介绍的对地形贴图做混合的方案上。

三角洲行动提出的方案是这样的,在地形计算的时候,从对材质ID图的四个像素采样退化成对三个像素采样,这样一来既能减少地形混合的时候的采样次数,二来相较于采样四个像素,三个像素多了一个斜向45度的效果,能够减轻一些地形的块面感。不过他们也有语焉不详的地方,虽然只采样三个像素能够提供斜向45度,但是对于斜向-45度,仅使用同一种方式采样三个像素是不能消除这个方向的块面感的,当然想必他们也有对应的解决方案就是了。此外他们声称材质ID图是一张R8的贴图,但这张贴图里面怎么会有5bit的下层ID,5bit的上层ID,再有3bit的权重值呢?我只能认为这张材质ID图实际上只包含了一个5bit的材质ID和3bit的权重了,这个3bit的权重值会在和另外几个像素混合时使用到。

不过三次采样倒是让我想起了Hex Tiling。在Practical Real-Time Hex-Tiling里介绍了一种通过六边形平铺来降低平铺时纹理重复感的算法,这种算法正巧需要对主贴图采样三次(不考虑随机采样的话)。Github中也能找到参考的代码

这样一来我们就能在三角洲行动的方案上再提出一种新的地形混合的方法了。我们同样是采样三个像素,不过我们在地形中会将这三个像素用等边三角形的方式排布,而不是目前所用的直角三角形。所以我们的流程是,先将当前的世界空间或者uv做一次三角形格点的变换,使用变换后的格点采样材质ID图获得三个材质ID和权重,再使用获得的数据和本身的六边形平铺的权重进行混合,就能得到我们最终的混合后的地形材质了。如果把我们的材质ID图平铺到世界空间,看上去应该是这样的:

Hex Tiled MatIDTex

生成材质ID图

为了快速生成材质ID图(我可不想手画),我们考虑使用Compute Shader通过Perlin Noise生成材质ID,使用普通的hash生成权重。为了使我们的材质ID图也能正常的平铺,我们在计算Perlin Noise的时候,要注意使用取模的运算将计算的uv值限制在同一个范围内。

我们只需要一个8bit的数据,但是由于Unity保存贴图的种种限制,我们可以将R8_Uint的数据除以255转换成R8_UNorm类型的数据再储存到贴图中。

GenerateMatIDComputeShader.compute

#pragma kernel MatIDGenMain

RWTexture2D<float> _RW_MatIDTex;
float4 _TextureSize;

// https://www.shadertoy.com/view/4djSRW
float hash12(float2 p)
{
	float3 p3  = frac(float3(p.xyx) * .1031);
    p3 += dot(p3, p3.yzx + 33.33);
    return frac((p3.x + p3.y) * p3.z);
}

float2 repeat(float2 coord, float2 size, float2 offset)
{
	return coord - floor(coord / size) * size + offset;
}

float encode(int weight, int index)
{
    int encoded = (weight << 5) | index;
    return float(encoded) / 255.0f;
}

float noise(float2 p )
{
    float2 i = floor( p );
    float2 f = frac( p );	
    float2 u = f*f*f*(f*(f*6.0-15.0)+10.0);

    float2 ts = _TextureSize.xy / 32.0f;
    float2 offset = 50.0f;
    float rv = lerp( lerp( hash12( repeat(i + float2(0.0,0.0), ts, offset) ), 
                     hash12( repeat(i + float2(1.0,0.0), ts, offset) ), u.x),
                lerp( hash12( repeat(i + float2(0.0,1.0), ts, offset) ), 
                     hash12( repeat(i + float2(1.0,1.0), ts, offset)), u.x), u.y);
    return rv * 2.0f - 1.0f;
}

[numthreads(8,8,1)]
void MatIDGenMain (uint3 id : SV_DispatchThreadID)
{
    float hashVal1 = hash12(float2(id.xy) + 12.3f);
    int weight = int(floor(hashVal1 * 8.0f));

    float rv = 0.0f;
    float2 uv = float2(id.xy) / 32.0f + 50.0f;
	rv  = 0.5000*noise( uv ); uv *=2.0f;
	rv += 0.2500*noise( uv ); uv *=2.0f;
	rv += 0.1250*noise( uv ); uv *=2.0f;
	rv += 0.0625*noise( uv );

    rv = rv * 0.5f + 0.5f;
    int index = int(rv * 32.0f);

    float returnVal = encode(weight, index);
    _RW_MatIDTex[id.xy] = returnVal;
}

MatIDGenerator.cs

using UnityEngine;
using UnityEditor;
using System.IO;

public class MatIDGenerator : EditorWindow
{
    private TextureSize textureSize = TextureSize._256x256;
    private ComputeShader computeShader;
    private string savePath = "Assets/HexTiling/MatID";
    private static readonly string suffix = ".tga";

    private RenderTexture rt;

    private enum TextureSize
    {
        _256x256 = 256,
        _512x512 = 512,
        _1024x1024 = 1024,
    }

    Rect rect
    {
        get { return new Rect(20.0f, 20.0f, position.width - 40.0f, position.height - 10.0f); }
    }

    private void EnsureRT()
    {
        if (rt == null || rt.width != (int)textureSize || rt.height != (int)textureSize)
        {
            if (rt != null) rt.Release();
            RenderTextureDescriptor desc = new RenderTextureDescriptor
            {
                width = (int)textureSize,
                height = (int)textureSize,
                volumeDepth = 1,
                dimension = UnityEngine.Rendering.TextureDimension.Tex2D,
                depthBufferBits = 0,
                msaaSamples = 1,
                graphicsFormat = UnityEngine.Experimental.Rendering.GraphicsFormat.R8G8B8A8_UNorm,
                enableRandomWrite = true
            };
            rt = new RenderTexture(desc);
            if (!rt.IsCreated()) rt.Create();
        }
    }


    [MenuItem("zznewclear13/Mat ID Generator")]
    public static void Init()
    {
        MatIDGenerator window = GetWindow<MatIDGenerator>("Mat ID Generator");

        window.Show();
        window.Repaint();
        window.Focus();
    }

    private void OnGUI()
    {
        using (new GUILayout.AreaScope(rect))
        {
            computeShader = (ComputeShader)EditorGUILayout.ObjectField("Compute Shader", computeShader, typeof(ComputeShader), false);
            textureSize = (TextureSize)EditorGUILayout.EnumPopup("Output Texture Size", textureSize);
            savePath = EditorGUILayout.TextField("Save Path", savePath);

            using (new EditorGUI.DisabledGroupScope(!computeShader))
            {
                if (GUILayout.Button("Generate!", new GUILayoutOption[] { GUILayout.Height(30.0f) }))
                {
                    GenerateMatID();
                }
            }
        }
    }

    private void GenerateMatID()
    {
        EnsureRT();
        float ts = (float)textureSize;

        int kernelID = computeShader.FindKernel("MatIDGenMain");
        computeShader.GetKernelThreadGroupSizes(kernelID, out uint x, out uint y, out uint z);
        Vector2Int dispatchCount = new Vector2Int(Mathf.CeilToInt(ts / x),
                                                    Mathf.CeilToInt(ts / y));
        computeShader.SetTexture(kernelID, "_RW_MatIDTex", rt);
        computeShader.SetVector("_TextureSize", new Vector4(ts, ts, 1.0f / ts, 1.0f / ts));
        computeShader.Dispatch(kernelID, dispatchCount.x, dispatchCount.y, 1);
        SaveRenderTextureToFile(rt);
    }

    private void SaveRenderTextureToFile(RenderTexture rt)
    {
        RenderTexture prev = RenderTexture.active;
        RenderTexture.active = rt;

        int ts = (int)textureSize;
        Texture2D toSave = new Texture2D(ts, ts, TextureFormat.R8, false, true);
        toSave.ReadPixels(new Rect(0.0f, 0.0f, ts, ts), 0, 0);
        byte[] bytes = toSave.EncodeToTGA();
        FileStream fs = File.OpenWrite(savePath + suffix);
        fs.Write(bytes);
        fs.Close();
        AssetDatabase.Refresh();
        
        TextureImporter ti = (TextureImporter)AssetImporter.GetAtPath(savePath + suffix);
        ti.mipmapEnabled = false;
        ti.sRGBTexture = false;
        ti.textureCompression = TextureImporterCompression.Uncompressed;
        ti.SaveAndReimport();
        
        Texture2D tempTexture = AssetDatabase.LoadAssetAtPath<Texture2D>(savePath + suffix);
        EditorGUIUtility.PingObject(tempTexture);

        RenderTexture.active = prev;
    }

    private void OnDestroy()
    {
        if (rt != null) rt.Release();
    }
}

对地形贴图做混合

为了简便,我们只对地形的Base Color进行混合,要想对法线或者是三平面映射进行混合,Hex Tiling的代码案例里也提供了相应的函数。我额外的使用了一个_Shape参数,数值越小就越多地显示出六边形的特征,越大则越多地显示出权重混合的特征。权重混合应该还有更好的方式,比如当三角形的两个点材质ID一致的时候,将混合的效果从三个权重混合简化成两个权重混合,不过这个后续有时间再考虑吧。这个Shader也能支持32张地形贴图的混合,第142行改成index = (data & 31);即可,不过我可没那么多张贴图,就限制到4张了。

HexTilingShader.shader

// MIT License
// 
// Copyright (c) 2024 zznewclear@gmail.com
// Copyright (c) 2022 mmikk
// 
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// 
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// 
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

Shader "zznewclear13/HexTilingShader"
{
    Properties
    {
		_MatIDTex ("Mat ID Texture", 2D) = "black" {}
		_TerrainMainTex ("Terrain Main Tex", 2DArray) = "" {}

		_Rotation ("Rotation", Range(0, 1)) = 0.5
		_Shape ("Shape", Range(0, 1)) = 0.5
		_G_EXP ("G Exp", Range(1, 10)) = 3.0
    }

    HLSLINCLUDE
	#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

	#define M_PI 3.1415926f
	static float g_fallOffContrast = 0.6;
	static float g_exp = 7;

	Texture2DArray _TerrainMainTex;
	Texture2D<float> _MatIDTex;
    CBUFFER_START(UnityPerMaterial)
	SamplerState sampler_MainTex;
	SamplerState sampler_TerrainMainTex;
	SamplerState sampler_PointRepeat;
	float4 _MatIDTex_TexelSize;
	float _Rotation;
	float _Shape;
	float _G_EXP;
    CBUFFER_END

    struct Attributes
    {
        float4 positionOS   : POSITION;
        float2 texcoord     : TEXCOORD0;
    };

    struct Varyings
    {
        float4 positionCS   : SV_POSITION;
        float2 uv           : TEXCOORD0;
        float3 positionWS   : TEXCOORD1;
    };

    Varyings vert(Attributes input)
    {
        Varyings output = (Varyings)0;
        VertexPositionInputs vpi = GetVertexPositionInputs(input.positionOS.xyz);
        output.positionCS = vpi.positionCS;
        output.uv = input.texcoord;
        output.positionWS = vpi.positionWS;
        return output;
    }

    float2 hash22(float2 p)
	{
		float3 p3 = frac(float3(p.xyx) * float3(.1031, .1030, .0973));
		p3 += dot(p3, p3.yzx+33.33);
		return frac((p3.xx+p3.yz)*p3.zy);
	}
   
    void TriangleGrid(out float w1, out float w2, out float w3, 
				  out int2 vertex1, out int2 vertex2, out int2 vertex3,
				  float2 st)
    {
	    // Scaling of the input
	    st *= 2 * sqrt(3);

	    // Skew input space into simplex triangle grid
	    const float2x2 gridToSkewedGrid = 
		    float2x2(1.0, -0.57735027, 0.0, 1.15470054);
	    float2 skewedCoord = mul(gridToSkewedGrid, st);

	    int2 baseId = int2( floor ( skewedCoord ));
	    float3 temp = float3( frac( skewedCoord ), 0);
	    temp.z = 1.0 - temp.x - temp.y;

	    float s = step(0.0, -temp.z);
	    float s2 = 2*s-1;

	    w1 = -temp.z*s2;
	    w2 = s - temp.y*s2;
	    w3 = s - temp.x*s2;

	    vertex1 = baseId + int2(s,s);
	    vertex2 = baseId + int2(s,1-s);
	    vertex3 = baseId + int2(1-s,s);
    }

    float2 MakeCenST(int2 Vertex)
    {
	    float2x2 invSkewMat = float2x2(1.0, 0.5, 0.0, 1.0/1.15470054);

	    return mul(invSkewMat, Vertex) / (2 * sqrt(3));
    }

    float2x2 LoadRot2x2(int2 idx, float rotStrength)
    {
	    float angle = abs(idx.x*idx.y) + abs(idx.x+idx.y) + M_PI;

	    angle = fmod(angle, 2*M_PI); 
	    if(angle<0) angle += 2*M_PI;
	    if(angle>M_PI) angle -= 2*M_PI;

	    angle *= rotStrength;
	    float cs = cos(angle), si = sin(angle);

	    return float2x2(cs, -si, si, cs);
    }

	int2 repeat(int2 coord, float2 size)
	{
		return coord - int2(floor(float2(coord) / size) * size);
	}

	void decodeData(int data, out float weight, out int index)
	{
		index = (data & 31) % 4;
		int iWeight = (data>>5)&7;
		weight = float(iWeight + 1) / 8.0f;
	}

    float4 frag(Varyings input) : SV_TARGET
    {
        float2 xy = input.positionWS.xz * 0.3f;

		float4 color;

		float w1, w2, w3;
	    int2 vertex1, vertex2, vertex3;
	    TriangleGrid(w1, w2, w3, vertex1, vertex2, vertex3, xy);
	    int2 rect1 = vertex1 + int2(vertex1.y / 2, 0);
	    int2 rect2 = vertex2 + int2(vertex2.y / 2, 0);
	    int2 rect3 = vertex3 + int2(vertex3.y / 2, 0);

		float2 size = _MatIDTex_TexelSize.zw;
		int data1 = int(_MatIDTex.Load(int3(repeat(rect1, size), 0)) * 255.0f + 0.5f);
		int data2 = int(_MatIDTex.Load(int3(repeat(rect2, size), 0)) * 255.0f + 0.5f);
		int data3 = int(_MatIDTex.Load(int3(repeat(rect3, size), 0)) * 255.0f + 0.5f);

		float weight1, weight2, weight3;
		int index1, index2, index3;
		decodeData(data1, weight1, index1);decodeData(data2, weight2, index2);decodeData(data3, weight3, index3);
		float3 weights = float3(weight1, weight2, weight3);
		float weightSum = dot(weights, float3(1.0f, 1.0f, 1.0f));
		weights /= weightSum;
		weights = lerp(1.0f, weights, _Shape);

		float rotStrength = _Rotation;
		float2x2 rot1 = LoadRot2x2(vertex1, rotStrength);
	    float2x2 rot2 = LoadRot2x2(vertex2, rotStrength);
	    float2x2 rot3 = LoadRot2x2(vertex3, rotStrength);

	    float2 cen1 = MakeCenST(vertex1);
	    float2 cen2 = MakeCenST(vertex2);
	    float2 cen3 = MakeCenST(vertex3);

	    float2 st1 = mul(xy - cen1, rot1) + cen1 + hash22(vertex1);
	    float2 st2 = mul(xy - cen2, rot2) + cen2 + hash22(vertex2);
	    float2 st3 = mul(xy - cen3, rot3) + cen3 + hash22(vertex3);

	    float2 dSTdx = ddx(xy), dSTdy = ddy(xy);
		float4 c1 = _TerrainMainTex.SampleGrad(sampler_TerrainMainTex, float3(st1, index1), mul(dSTdx, rot1), mul(dSTdy, rot1));
		float4 c2 = _TerrainMainTex.SampleGrad(sampler_TerrainMainTex, float3(st2, index2), mul(dSTdx, rot2), mul(dSTdy, rot2));
		float4 c3 = _TerrainMainTex.SampleGrad(sampler_TerrainMainTex, float3(st3, index3), mul(dSTdx, rot3), mul(dSTdy, rot3));
	
		// use luminance as weight
	    float3 Lw = float3(0.299, 0.587, 0.114);
	    float3 Dw = float3(dot(c1.xyz,Lw),dot(c2.xyz,Lw),dot(c3.xyz,Lw));
	    Dw = lerp(1.0, Dw, g_fallOffContrast);
	    float3 W = Dw*pow(float3(w1 * weights.x, w2 * weights.y, w3 * weights.z), _G_EXP);
	    W /= (W.x+W.y+W.z);

	    color = W.x * c1 + W.y * c2 + W.z * c3;
        return color;
    }

    ENDHLSL

    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            ENDHLSL
        }
    }
}

最终的效果

效果看上去就是这样了,左边是远景的截图,从中我们看不出任何平铺的痕迹,右边是近景的截图,该怎么说呢,看上去好像很有机地混合在了一起。。。

Hex Tiling

后记

呃呃这篇不知道该扯些啥了,反正是一个比较简单的效果。六边形平铺好早之前就想做了,终于有时间一试。大体上效果还是可以的,但是当出现垂直的线条的时候,会有左一个六边形,右一个六边形的情况出现,我觉得可能可以通过手动修改权重值来优化这个问题,或者是上面提到的三个权重变两个的方法,不过也懒得再去试了。