IT圈老男孩1 发表于 2023-1-11 10:27

用图形GPGPU做物理仿真(5)·GPU粒子系统(渲染)

这一篇文章我们继续讨论基础的GPU粒子系统,我打算介绍一下自己引擎渲染方面的一些架构,后续我们会开发一系列可视化的材质要么用于调试,要么用于最终的效果呈现,所以无论如何有一个渲染系统都会是必须的。我自己的引擎和Unity在用户侧的架构是一致是,采用组件系统架构,但在底层图形API上,因为我只做Metal,所以尽可能不做很厚的封装,而是将原始的Metal概念暴露出来,这样做可以更加灵活得满足各种算法的需求。
但是,渲染架构是一个比较大的话题,我不太想讲的特别的细,我计划将渲染架构拆分到后续的系列文章当中去,从而不至于让渲染架构的细节掩盖了我们做模拟的初心。其实在我做 WebGPU 的时候搭建过一个网站,里面已经对渲染有一个比较完整的介绍,但是在Metal上很多事情会更加容易进行,因此存在一些小的差异,如果更想了解渲染架构或者在Vulkan和WebGPU上怎么搭建的话,可以参考:
用户接口

着色器

首先我们先来看怎么去用这套架构,后面再逐步介绍这套用户API层背后的逻辑。对于渲染来说,我们最关心的是材质和着色器,所以存在一个基础的Material类型:
/// Material.
open class Material {
    /// Name.
    public var name: String = ""
    /// Shader data.
    public var shaderData: ShaderData

    /// Shader used by the material.
    public var shader: = []

    public func getRenderState(_ index: Int) -> RenderState {
      shader.renderState!
    }

    /// Create a material instance.
    /// - Parameters:
    ///   - device: Metal Device
    ///   - name: Material name
    public init(_ engine: Engine, _ name: String = "") {
      shaderData = ShaderData(engine)
      self.name = name
    }
}一个材质支持多个 ShaderPass,由 ShaderPass 负责管理一对顶点和片段着色器,或者一个计算着色器。
/// Shader pass
public class ShaderPass {
    internal var _library: MTLLibrary!
    internal var _shaders:
    internal var _renderState: RenderState? = nil

    public var renderState: RenderState? {
      get {
            _renderState
      }
    }
   
    public init(_ library: MTLLibrary, _ computeShader: String) {
      _shaders =
      _library = library
    }
   
    public init(_ library: MTLLibrary, _ vertexSource: String, _ fragmentSource: String?) {
      if fragmentSource == nil {
            _shaders =
      } else {
            _shaders =
      }
      _library = library
      _renderState = RenderState()
      setBlendMode(.Normal)
    }
}在其中还可以调节 RenderState,控制混合模式,透明等等管线的状态。
public class RenderState {
    /// Blend state.
    public var blendState: BlendState = BlendState()
    /// Depth state.
    public var depthState: DepthState = DepthState()
    /// Stencil state.
    public var stencilState: StencilState = StencilState()
    /// Raster state.
    public var rasterState: RasterState = RasterState()
    /// Render queue type.
    public var renderQueueType: RenderQueueType = RenderQueueType.Opaque

    func _apply(_ pipelineDescriptor: MTLRenderPipelineDescriptor,
                _ depthStencilDescriptor: MTLDepthStencilDescriptor,
                _ renderEncoder: MTLRenderCommandEncoder,
                _ frontFaceInvert: Bool) {
      blendState._apply(pipelineDescriptor, renderEncoder)
      depthState._apply(depthStencilDescriptor)
      stencilState._apply(depthStencilDescriptor, renderEncoder)
      rasterState._apply(frontFaceInvert, renderEncoder)
    }
}所以对于使用来说,例如PBR材质,可以采用继承 Material,然后扩展定义自己的材质类型:
public class PBRBaseMaterial: BaseMaterial {
    public override init(_ engine: Engine, _ name: String = "") {
      super.init(engine, name)
      shader.append(ShaderPass(engine.library(), "vertex_pbr", "fragment_pbr"))

      shaderData.enableMacro(NEED_WORLDPOS.rawValue)
      shaderData.enableMacro(NEED_TILINGOFFSET.rawValue)
      shaderData.setData(PBRBaseMaterial._pbrBaseProp, _pbrBaseData)
      shaderData.setData(UnlitMaterial._tilingOffsetProp, _tilingOffset)
    }
}其中 “vertex_pbr” 和 “fragment_pbr” 表示的是library 当中顶点和片段着色器的名字,对于 Metal 来说, Library可以做预编译,因此每一个 MTLFunction 都指向的是某个特定 MTLLibrary 当中的函数。
着色器数据与宏

和我们之前介绍计算着色器的架构一样,我们也使用 ShaderData 控制要绑定到着色器上数据。当然这里的绑定会更加复杂一些。因为对于计算来说,一般都采用 Device Memory,数据全部由GPU进行读写,但渲染涉及到大量用户侧的交互,所以CPU也会参与进来,这时候要注意一些读写的同步。这些细节这里不展开了,我在这里介绍另外一个问题的处理:宏的使用。
熟悉GLSL的肯定对宏也不陌生,但现代的图形API都会有类似FunctionConstant的概念,我理解有点类似C++当中的 constexpr 表达式,在编译期就能得到的常量表达式,而且有更好的类型控制,不像是宏一样会有各种各样的问题。在构造 MTLFunction 时可以输入 MTLFunctionConstantValues:
func createProgram(_ source: String,
                   _ macroInfo: ShaderMacroCollection) -> MTLFunction? {
    let functionConstants = makeFunctionConstants(macroInfo)

    do {
      return try _library.makeFunction(name: source, constantValues: functionConstants)
    } catch {
      print("Unexpected error: \(error).")
      return nil
    }
}对于用户侧,是使用 ShaderMacroCollection 收集所有的宏信息,主要的接口定义在 ShaderData 当中:
extension ShaderData {
    /// Enable macro.
    /// - Parameter name: Macro name
    public func enableMacro(_ name: UInt32) {
      _macroCollection._value = (1, .bool)
    }

    /// Enable macro.
    /// - Parameters:
    ///   - name: Macro name
    ///   - value: Macro value
    public func enableMacro(_ name: UInt32, _ value: (Int, MTLDataType)) {
      _macroCollection._value = value
    }

    /// Disable macro
    /// - Parameter name: Macro name   
    public func disableMacro(_ name: UInt32) {
      _macroCollection._value.removeValue(forKey: UInt16(name))
    }
}实际上,FunctionConstant 在着色器侧的声明只需要两个值,一个ID编号和默认值,我在引擎当中只支持了Bool类型和Int类型,所以着色器当中定义两个构造宏:
#define DeclBoolMacro(NAME, INDEX) \
constant bool NAME##_decl []; \
constant bool NAME = is_function_constant_defined(NAME##_decl) ? NAME##_decl : false;

#define DeclIntMacro(NAME, INDEX) \
constant int NAME##_decl []; \
constant int NAME = is_function_constant_defined(NAME##_decl) ? NAME##_decl : 0;

DeclBoolMacro(hasBlendShape, HAS_BLENDSHAPE)
DeclIntMacro(blendShapeCount, BLENDSHAPE_COUNT)
...
构造宏的第一个声明了一个 function_constant,第二个则是判断 function_constant 有没有设置,如果没有的话则提供一个默认的值,这样一来对于复杂的Shader就不用刻意把用不到的 function_constant 设置进来。由于 Metal 和 Swift 可以通过一个 .h 头文件桥接在一起,因此在 .h 头文件当中声明 function_constant 的名字并且赋予编号,例如:
// int have no verb, other will use:
// HAS_ : Resouce
// OMMIT_ : Omit Resouce
// NEED_ : Shader Operation
// IS_ : Shader control flow
// _COUNT: type int constant
typedef enum {
    HAS_UV =                           65535,
    HAS_NORMAL =                         65534,
    HAS_TANGENT =                        65533,
    HAS_VERTEXCOLOR =                  65532,

    // Blend Shape
    HAS_BLENDSHAPE =                     65531,
    BLENDSHAPE_COUNT =                   65530,
    HAS_BLENDSHAPE_NORMAL =            65529,
    HAS_BLENDSHAPE_TANGENT =             65528,
...
}
对于内置的宏变量,我采用倒序从最大的Index值开始编号,对于用户自定义的宏,则可以用正序从0开始编号,这样不容易起冲突。有了这些,我们在着色器当中就可以写类似以下的代码:
typedef struct {
    float3 POSITION [];
    float3 NORMAL [];
    float4 COLOR_0 [];
    float4 WEIGHTS_0 [];
    float4 JOINTS_0 [];
    float4 TANGENT [];
    float2 TEXCOORD_0 [];
} VertexIn;
通过 function_constant 我们可以让着色器灵活支持各种各样的编译类型组合,也可以直接使用 if 语句而不用担心这里有分支的问题,例如:
if (isMetallicWorkFlow) {
    shading.u_metal = u_pbr.metallic;
    shading.u_roughness = u_pbr.roughness;
} else {
    shading.u_PBRSpecularColor = u_pbrSpecular.specularColor;
    shading.u_glossiness = u_pbrSpecular.glossiness;
}
这样一来用户侧开启某个宏定义只需写:
shaderData.enableMacro(IS_METALLIC_WORKFLOW.rawValue)IS_METALLIC_WORKFLOW 是我们声明的枚举名字,同时这一枚举自动转换成某个 uint32 的 index 值用于构造特殊的着色器版本。
<hr/>粒子的渲染

有关渲染的内容我们先讨论到这里,抓紧时间先进入到粒子的渲染上,解决当前最主要的问题。我们先定义一个新的材质ParticlePointMaterial:
/// particle point Material.
public class ParticlePointMaterial: BaseMaterial {
    private static let highlightProperty = "hlIndex"
    private static let radiusProperty = "pointRadius"
    private static let scaleProperty = "pointScale"
    var _pointScale: Float = 0
    var _pointRadius: Float = 0
    var _highlightIndex: UInt32 = 0

    public var pointScale: Float {
      get {
            _pointScale
      }
      set {
            _pointScale = newValue
            shaderData.setData(ParticlePointMaterial.scaleProperty, _pointScale)
      }
    }
   
    public var pointRadius: Float {
      get {
            _pointRadius
      }
      set {
            _pointRadius = newValue
            shaderData.setData(ParticlePointMaterial.radiusProperty, _pointRadius)
      }
    }

    public var highlightIndex: UInt32 {
      get {
            _highlightIndex
      }
      set {
            _highlightIndex = newValue
            shaderData.setData(ParticlePointMaterial.highlightProperty, _highlightIndex)
      }
    }

    public override init(_ engine: Engine, _ name: String = "") {
      super.init(engine, name)
      shader.append(ShaderPass(engine.library("flex.shader"), "vertex_particle", "fragment_particle"))

      shaderData.setData(ParticlePointMaterial.scaleProperty, _pointScale)
      shaderData.setData(ParticlePointMaterial.highlightProperty, _highlightIndex)
    }其实材质子类最大的作用是通过一些计算属性的定义让使用材质更加方便一些。其最核心的部分则是在构造函数当中已经记录结束了。对于这样一个材质,我们使用的是位于 flex.shader 这个 MTLLibrary 当中,名为 vertex_particle 和 fragment_particle 的这两个着色器。有了这个材质,然后使用我们计算出来的 Position 数据,就可以按照 Point 的绘制方式进行渲染:
fileprivate func createParticleRenderer(_ rootEntity: Entity, _ particleSystem: ParticleSystemData) {
    let descriptor = MTLVertexDescriptor()
    let desc = MTLVertexAttributeDescriptor()
    desc.format = .float3
    desc.offset = 0
    desc.bufferIndex = 0
    descriptor.attributes = desc
    descriptor.layouts.stride = 16

    let particleMesh = Mesh()
    particleMesh._vertexDescriptor = descriptor
    let maxNumber: Int = particleSystem.numberOfParticles
    let subMesh = particleMesh.addSubMesh(0, maxNumber, .point)
    particleMesh._setVertexBufferBinding(0, particleSystem.positions)
    let particleMtl = ParticlePointMaterial(engine)
    particleMtl.pointRadius = 5
    particleMtl.pointScale = 10
      
    let particleEntity = rootEntity.createChild()
    let renderer: MeshRenderer = particleEntity.addComponent()
    renderer.mesh = particleMesh
    renderer.setMaterial(particleMtl)
}这段代码我就不做解释了,和 Unity 的 MeshRenderer 组件的使用方式是完全一致的。唯一需要注意的是,虽然Position 都是 float3 数组,但float3 实际上会对齐成 float4,所以stride 等于16而不是12.
粒子着色器

好了讲了这么一大堆的东西,终于可以讲大家最感兴趣的着色器怎么写了。在上述渲染中,我们使用了Point 模式渲染每一个粒子点,所以在顶点着色器当中,我们需要设定每一个粒子的 pointSize:
typedef struct {
    float3 position [];
} VertexIn;

typedef struct {
    float4 position [];
    float pointSize [];
    uint v_id;
} VertexOut;

vertex VertexOut vertex_particle(const VertexIn vertexIn [],
                                 uint v_id [],
                                 constant uint &hlIndex [],
                                 constant float &pointRadius [],
                                 constant float &pointScale [],
                                 constant CameraData &u_camera []) {
    VertexOut out;
   
    float4 eyePos = u_camera.u_viewMat * float4(vertexIn.position, 1.0);
    float dist = length(float3(eyePos / eyePos.w));
   
    out.pointSize = (v_id == hlIndex ? 2 : 1) * pointRadius * (pointScale / dist);
    out.position = u_camera.u_projMat * eyePos;
    out.v_id = v_id;
   
    return out;
}
着色器当中使用的 u_camera 是相机有关的数据,在我的渲染架构当中,和ComputePass相对自由的 ShaderData使用不同,我只在4个地方允许定义ShaderData,分别是Scene,Camera,Renderer,Material,其中 Camera 会负责 CameraData 数据,并且自动绑定到管线当中。绑定也是利用了管线的反射,但会有一些小的优化,后面有机会再做介绍。
这样渲染的粒子其实是billboard,是朝向屏幕的方形面片,所以我们需要在片段着色器当中把他处理成圆的,并且增加光照使得粒子之间可以被区分开来:
fragment float4 fragment_particle(VertexOut in [],
                                  constant uint &hlIndex [],
                                  float2 point []) {
    return hls_shading(in.v_id, hlIndex, point);
}

float4 hls_shading(uint iid, uint hlIndex, float2 point) {   
    float3 rgb = float3(1, 1, 1);
   
    float3 lightDir = normalize(float3(1, -1, 1));
    float x = 2 * point.x - 1;
    float y = 2 * point.y - 1;
    float pho = x * x + y * y;
    float z = sqrt(1 - pho);
    if (pho > 1) discard_fragment();
   
    float4 rgba = float4(dot(lightDir, float3(x, y, z)) * rgb, 1);
    float4 white = float4(dot(lightDir, float3(x, y, z)) * float3(1, 1, 1), 1) + 0.2;
   
    if (iid == hlIndex)
      return white;
    else
      return rgba;
}首先判断点的位置和原点半径的距离,对于超出距离的就直接discard掉,这样就可以绘制一个球,结合光照和球的表面法线就可以有明暗的感觉。
总结

实在写的太长了,但因为渲染架构本身和模拟一样的复杂,所以不得不啰嗦了这么多。我非常建议在开发GPGPU模拟代码之前,先搭建渲染器的结构,并且渲染器可以很自然地融合计算着色器的结果。因为有太多的调试都必须依靠渲染的展示。本文的介绍还是非常粗糙的,具体代码可以参考:
在下一篇文章当中我们会引入SDF碰撞器,并且对SDF用RayMarching进行渲染,同时融合到这一篇文章实现的粒子渲染的场景中,从而可以观察碰撞器的位置。
页: [1]
查看完整版本: 用图形GPGPU做物理仿真(5)·GPU粒子系统(渲染)