After a bit of experimentation, I’ve managed to do everything I wanted…
Except the “getting the mesh data” part, I expect that there’s no function to do that outside of the C++ side so I guess I’ll have to stick to my GDExtension idea for that.
Anyways, for now, here’s the working solution for anyone who stumbles upon this, it should be pretty simple to convert to GDScript, but if you can’t manage to do it, feel free to contact me:
Abstract Compositor Effect class to simplify cleaning up:
using Godot;
public abstract partial class BaseCompositorEffect : CompositorEffect, ISerializationListener {
protected RenderingDevice? RenderingDevice { get; private set; }
public BaseCompositorEffect() : base() {
RenderingDevice ??= RenderingServer.GetRenderingDevice();
Callable.From(Construct).CallDeferred();
}
public override void _Notification(int what) {
base._Notification(what);
switch ((ulong)what) {
case NotificationPredelete:
Destruct();
break;
}
}
protected void Construct() {
if (RenderingDevice is null) return;
RenderingServer.CallOnRenderThread(Callable.From(() => ConstructBehaviour(RenderingDevice)));
}
protected void Destruct() {
if (RenderingDevice is null) return;
DestructBehaviour(RenderingDevice);
}
protected abstract void ConstructBehaviour(RenderingDevice renderingDevice);
protected abstract void DestructBehaviour(RenderingDevice renderingDevice);
public void OnBeforeSerialize() {
Destruct();
}
public void OnAfterDeserialize() {
Construct();
}
}
Helper Methods:
using System;
using Godot;
using Godot.Collections;
public static class CompositorExtensions {
public static Rid IndexBufferCreate(this RenderingDevice renderingDevice, ushort[] indices) {
byte[] byteIndices = indices.ToByteArray();
return renderingDevice.IndexBufferCreate((uint)indices.Length, RenderingDevice.IndexBufferFormat.Uint16, byteIndices);
}
public static Rid IndexBufferCreate(this RenderingDevice renderingDevice, uint[] indices) {
byte[] byteIndices = indices.ToByteArray();
return renderingDevice.IndexBufferCreate((uint)indices.Length, RenderingDevice.IndexBufferFormat.Uint32, byteIndices);
}
public static (Rid indexBuffer, Rid indexArray)? IndexArrayCreate(this RenderingDevice renderingDevice, ushort[] indices, uint indexOffset = 0) {
Rid indexBuffer = renderingDevice.IndexBufferCreate(indices);
if (!indexBuffer.IsValid) {
throw new ArgumentException("Index Buffer is Invalid");
}
Rid indexArray = renderingDevice.IndexArrayCreate(indexBuffer, indexOffset, (uint)indices.Length);
if (!indexArray.IsValid) {
throw new ArgumentException("Index Array is Invalid");
}
return (indexBuffer, indexArray);
}
public static (Rid indexBuffer, Rid indexArray) IndexArrayCreate(this RenderingDevice renderingDevice, uint[] indices, uint indexOffset = 0) {
Rid indexBuffer = renderingDevice.IndexBufferCreate(indices);
if (!indexBuffer.IsValid) {
throw new ArgumentException("Index Buffer is Invalid");
}
Rid indexArray = renderingDevice.IndexArrayCreate(indexBuffer, indexOffset, (uint)indices.Length);
if (!indexArray.IsValid) {
throw new ArgumentException("Index Array is Invalid");
}
return (indexBuffer, indexArray);
}
public static Rid VertexBufferCreate(this RenderingDevice renderingDevice, float[] vertices) {
if (vertices.Length % 3 != 0) throw new ArgumentException("Invalid number of values in the points buffer, there should be three float values per point.", nameof(vertices));
byte[] byteVertices = vertices.ToByteArray();
return renderingDevice.VertexBufferCreate((uint)byteVertices.Length, byteVertices);
}
public static (Rid vertexBuffer, Rid vertexArray) VertexArrayCreate(this RenderingDevice renderingDevice, float[] points, long vertexFormat) {
Rid vertexBuffer = renderingDevice.VertexBufferCreate(points);
if (!vertexBuffer.IsValid) {
throw new ArgumentException("Vertex Buffer is Invalid");
}
Rid vertexArray = renderingDevice.VertexArrayCreate((uint)(points.Length / 3), vertexFormat, [vertexBuffer]);
if (!vertexArray.IsValid) {
throw new ArgumentException("Vertex Array is Invalid");
}
return (vertexBuffer, vertexArray);
}
public static (Rid framebufferTexture, Rid framebuffer) FramebufferCreate(this RenderingDevice renderingDevice, RDTextureFormat textureFormat, RDTextureView textureView, RenderingDevice.TextureSamples textureSamples = RenderingDevice.TextureSamples.Samples1) {
Rid frameBufferTexture = renderingDevice.TextureCreate(textureFormat, textureView);
if (!frameBufferTexture.IsValid) {
throw new ArgumentException("Frame Buffer Texture is Invalid");
}
Array<RDAttachmentFormat> attachments = [
new RDAttachmentFormat() {
Format = textureFormat.Format,
Samples = textureSamples,
UsageFlags = (uint)textureFormat.UsageBits
}
];
long frameBufferFormat = renderingDevice.FramebufferFormatCreate(attachments);
Rid frameBuffer = renderingDevice.FramebufferCreate([frameBufferTexture], frameBufferFormat);
if (!frameBuffer.IsValid) {
throw new ArgumentException("Frame Buffer is Invalid");
}
return (frameBufferTexture, frameBuffer);
}
public static RDUniform AddIds(this RDUniform uniform, Span<Rid> ids) {
for (int i = 0; i < ids.Length; i++) {
uniform.AddId(ids[i]);
}
return uniform;
}
public static void ComputeListBindImage(this RenderingDevice device, long computeList, Rid shaderRid, Rid image, uint setIndex, int binding = 0) {
RDUniform uniform = new RDUniform() {
UniformType = RenderingDevice.UniformType.Image,
Binding = binding,
}.AddIds([image]);
device.ComputeListBindUniform(computeList, uniform, shaderRid, setIndex);
}
public static void ComputeListBindSampler(this RenderingDevice device, long computeList, Rid shaderRid, Rid image, Rid sampler, uint setIndex, int binding = 0) {
RDUniform uniform = new RDUniform() {
UniformType = RenderingDevice.UniformType.SamplerWithTexture,
Binding = binding,
}.AddIds([sampler, image]);
device.ComputeListBindUniform(computeList, uniform, shaderRid, setIndex);
}
public static void ComputeListBindColor(this RenderingDevice device, long computeList, Rid shaderRid, RenderSceneBuffersRD sceneBuffers, uint view, uint setIndex, int binding = 0) =>
device.ComputeListBindImage(computeList, shaderRid, sceneBuffers.GetColorLayer(view), setIndex, binding);
public static void ComputeListBindDepth(this RenderingDevice device, long computeList, Rid shaderRid, RenderSceneBuffersRD sceneBuffers, uint view, Rid sampler, uint setIndex, int binding = 0) =>
device.ComputeListBindSampler(computeList, shaderRid, sceneBuffers.GetDepthLayer(view), sampler, setIndex, binding);
public static void ComputeListBindUniform(this RenderingDevice device, long computeList, RDUniform uniform, Rid shaderRid, uint setIndex) {
Rid set = UniformSetCacheRD.GetCache(shaderRid, setIndex, [uniform]);
device.ComputeListBindUniformSet(computeList, set, setIndex);
}
public static byte[] ToByteArray(this Half[] halfs) {
byte[] bytes = new byte[halfs.Length * 2];
for (int i = 0; i < halfs.Length; i++) {
byte[] halfBytes = BitConverter.GetBytes(halfs[i]);
int j = i * 2;
bytes[j] = halfBytes[0];
bytes[j+1] = halfBytes[1];
}
return bytes;
}
public static byte[] ToByteArray(this float[] floats) {
byte[] bytes = new byte[floats.Length * 4];
for (int i = 0; i < floats.Length; i++) {
byte[] floatBytes = BitConverter.GetBytes(floats[i]);
int j = i * 4;
bytes[j] = floatBytes[0];
bytes[j+1] = floatBytes[1];
bytes[j+2] = floatBytes[2];
bytes[j+3] = floatBytes[3];
}
return bytes;
}
public static byte[] ToByteArray(this double[] doubles) {
byte[] bytes = new byte[doubles.Length * 8];
for (int i = 0; i < doubles.Length; i++) {
byte[] doubleBytes = BitConverter.GetBytes(doubles[i]);
int j = i * 8;
bytes[j] = doubleBytes[0];
bytes[j+1] = doubleBytes[1];
bytes[j+2] = doubleBytes[2];
bytes[j+3] = doubleBytes[3];
bytes[j+4] = doubleBytes[4];
bytes[j+5] = doubleBytes[5];
bytes[j+6] = doubleBytes[6];
bytes[j+7] = doubleBytes[7];
}
return bytes;
}
public static byte[] ToByteArray(this ushort[] ushorts) {
byte[] bytes = new byte[ushorts.Length * 2];
for (int i = 0; i < ushorts.Length; i++) {
byte[] ushortBytes = BitConverter.GetBytes(ushorts[i]);
int j = i * 2;
bytes[j] = ushortBytes[0];
bytes[j+1] = ushortBytes[1];
}
return bytes;
}
public static byte[] ToByteArray(this short[] shorts) {
byte[] bytes = new byte[shorts.Length * 2];
for (int i = 0; i < shorts.Length; i++) {
byte[] shortBytes = BitConverter.GetBytes(shorts[i]);
int j = i * 2;
bytes[j] = shortBytes[0];
bytes[j+1] = shortBytes[1];
}
return bytes;
}
public static byte[] ToByteArray(this uint[] uints) {
byte[] bytes = new byte[uints.Length * 4];
for (int i = 0; i < uints.Length; i++) {
byte[] uintBytes = BitConverter.GetBytes(uints[i]);
int j = i * 4;
bytes[j] = uintBytes[0];
bytes[j+1] = uintBytes[1];
bytes[j+2] = uintBytes[2];
bytes[j+3] = uintBytes[3];
}
return bytes;
}
public static byte[] ToByteArray(this int[] ints) {
byte[] bytes = new byte[ints.Length * 4];
for (int i = 0; i < ints.Length; i++) {
byte[] intBytes = BitConverter.GetBytes(ints[i]);
int j = i * 4;
bytes[j] = intBytes[0];
bytes[j+1] = intBytes[1];
bytes[j+2] = intBytes[2];
bytes[j+3] = intBytes[3];
}
return bytes;
}
public static byte[] ToByteArray(this ulong[] ulongs) {
byte[] bytes = new byte[ulongs.Length * 8];
for (int i = 0; i < ulongs.Length; i++) {
byte[] ulongBytes = BitConverter.GetBytes(ulongs[i]);
int j = i * 8;
bytes[j] = ulongBytes[0];
bytes[j+1] = ulongBytes[1];
bytes[j+2] = ulongBytes[2];
bytes[j+3] = ulongBytes[3];
bytes[j+4] = ulongBytes[4];
bytes[j+5] = ulongBytes[5];
bytes[j+6] = ulongBytes[6];
bytes[j+7] = ulongBytes[7];
}
return bytes;
}
public static byte[] ToByteArray(this long[] longs) {
byte[] bytes = new byte[longs.Length * 8];
for (int i = 0; i < longs.Length; i++) {
byte[] longBytes = BitConverter.GetBytes(longs[i]);
int j = i * 8;
bytes[j] = longBytes[0];
bytes[j+1] = longBytes[1];
bytes[j+2] = longBytes[2];
bytes[j+3] = longBytes[3];
bytes[j+4] = longBytes[4];
bytes[j+5] = longBytes[5];
bytes[j+6] = longBytes[6];
bytes[j+7] = longBytes[7];
}
return bytes;
}
}
Actual code:
using System;
using Godot;
[Tool]
[GlobalClass]
public partial class DrawAndComputeCompositorEffect : BaseCompositorEffect {
public static readonly StringName Context = "UnderwaterEffect";
public static readonly StringName WaterMapName = "water_map";
public static readonly StringName WaterDepthName = "water_depth";
[Export] private RDShaderFile? RenderShaderFile {
get => _renderShaderFile;
set {
_renderShaderFile = value;
if (RenderingDevice is not null) {
Destruct();
Construct();
}
}
}
private RDShaderFile? _renderShaderFile;
private Rid renderShader;
[Export] private RDShaderFile? ComputeShaderFile {
get => _computeShaderFile;
set {
_computeShaderFile = value;
if (RenderingDevice is not null) {
Destruct();
Construct();
}
}
}
private RDShaderFile? _computeShaderFile;
private Rid computeShader;
private Rid nearestSampler;
private readonly RDAttachmentFormat waterMapAttachmentFormat = new() {
Format = RenderingDevice.DataFormat.R16G16B16A16Unorm,
Samples = RenderingDevice.TextureSamples.Samples1,
UsageFlags = (uint)(RenderingDevice.TextureUsageBits.ColorAttachmentBit | RenderingDevice.TextureUsageBits.StorageBit)
};
private readonly RDAttachmentFormat waterDepthAttachmentFormat = new() {
Format = RenderingDevice.DataFormat.D32Sfloat,
Samples = RenderingDevice.TextureSamples.Samples1,
UsageFlags = (uint)RenderingDevice.TextureUsageBits.DepthStencilAttachmentBit
};
private long framebufferFormat;
private readonly RDVertexAttribute vertexAttribute = new() {
Format = RenderingDevice.DataFormat.R32G32B32Sfloat,
Location = 0,
Stride = sizeof(float) * 3,
};
private long vertexFormat;
private Rid renderPipeline;
private Rid computePipeline;
public DrawAndComputeCompositorEffect() : base() {
EffectCallbackType = EffectCallbackTypeEnum.PostTransparent;
}
public override void _RenderCallback(int effectCallbackType, RenderData renderData) {
base._RenderCallback(effectCallbackType, renderData);
// if (effectCallbackType != (long)EffectCallbackTypeEnum.PostTransparent) return;
if (RenderingDevice is null || _renderShaderFile is null || _computeShaderFile is null) return;
RenderSceneBuffers renderSceneBuffers = renderData.GetRenderSceneBuffers();
if (renderSceneBuffers is not RenderSceneBuffersRD sceneBuffers) return;
RenderSceneData renderSceneData = renderData.GetRenderSceneData();
if (renderSceneData is not RenderSceneDataRD sceneData) return;
uint viewCount = sceneBuffers.GetViewCount();
Vector2I renderSize = sceneBuffers.GetInternalSize();
if (renderSize.X == 0.0 && renderSize.Y == 0.0) {
throw new ArgumentException("Render size is incorrect");
}
uint xGroups = (uint)((renderSize.X - 1) / 8) + 1;
uint yGroups = (uint)((renderSize.Y - 1) / 8) + 1;
if (sceneBuffers.HasTexture(Context, WaterMapName)) {
// Reset the Color and Depth textures if their sizes are wrong
RDTextureFormat textureFormat = sceneBuffers.GetTextureFormat(Context, WaterMapName);
if (textureFormat.Width != renderSize.X || textureFormat.Height != renderSize.Y) {
sceneBuffers.ClearContext(Context);
}
}
if (! sceneBuffers.HasTexture(Context, WaterMapName)) {
// Create and cache the Map and Depth to create the Water Buffer
sceneBuffers.CreateTexture(Context, WaterMapName, waterMapAttachmentFormat.Format, waterMapAttachmentFormat.UsageFlags, waterMapAttachmentFormat.Samples, renderSize, viewCount, 1, true);
sceneBuffers.CreateTexture(Context, WaterDepthName, waterDepthAttachmentFormat.Format, waterDepthAttachmentFormat.UsageFlags, waterDepthAttachmentFormat.Samples, renderSize, viewCount, 1, true);
}
Color[] clearColors = [new Color(0, 0, 0, 0)];
for (uint view = 0; view < viewCount; view++) {
Rid waterMap = sceneBuffers.GetTextureSlice(Context, WaterMapName, view, 0, 1, 1);
Rid waterDepth = sceneBuffers.GetTextureSlice(Context, WaterDepthName, view, 0, 1, 1);
// Include the Map and Depth from earlier
Rid waterBuffer = RenderingDevice.FramebufferCreate([waterMap, waterDepth], framebufferFormat);
if (! waterBuffer.IsValid) {
throw new ArgumentException("Water Mask Frame Buffer is Invalid");
}
// TODO: get the mesh data instead of this hard-coded data
float[] vertCoords = [
5f, 0f, 0f,
0f, 0f, 0f,
0f, 5f, 0f,
5f, 5f, 0f,
5f, 5f, -5f,
5f, 0f, -5f,
];
uint[] vertIndices = [
0, 1, 2,
0, 2, 3,
0, 3, 4,
0, 4, 5,
];
(_, Rid vertexArray) = RenderingDevice.VertexArrayCreate(vertCoords, vertexFormat);
(_, Rid indexArray) = RenderingDevice.IndexArrayCreate(vertIndices);
Projection projection = sceneData.GetViewProjection(view);
Projection viewMatrix = new(sceneData.GetCamTransform().Inverse());
// World-space -> Clip-space Matrix to be used in the rendering shader
Projection WorldToClip = projection * viewMatrix;
// Eye Offset for fancy VR multi-view
Vector3 eyeOffset = sceneData.GetViewEyeOffset(view);
// Unfolding into a push constant
float[] renderPushConstant = [
WorldToClip.X.X, WorldToClip.X.Y, WorldToClip.X.Z, WorldToClip.X.W,
WorldToClip.Y.X, WorldToClip.Y.Y, WorldToClip.Y.Z, WorldToClip.Y.W,
WorldToClip.Z.X, WorldToClip.Z.Y, WorldToClip.Z.Z, WorldToClip.Z.W,
WorldToClip.W.X, WorldToClip.W.Y, WorldToClip.W.Z, WorldToClip.W.W,
eyeOffset.X, eyeOffset.Y, eyeOffset.Z, 0, // Pad with a zero, because the push constant needs to contain a multiple of 16 bytes (4 floats)
];
byte[] renderPushConstantBytes = renderPushConstant.ToByteArray();
// Render the Geometry (see vertCoords and vertIndices) to an intermediate framebuffer To use later
RenderingDevice.DrawCommandBeginLabel("Render Water Mask", new Color(1f, 1f, 1f));
long drawList = RenderingDevice.DrawListBegin(waterBuffer, RenderingDevice.InitialAction.Clear, RenderingDevice.FinalAction.Store, RenderingDevice.InitialAction.Clear, RenderingDevice.FinalAction.Discard, clearColors);
RenderingDevice.DrawListBindRenderPipeline(drawList, renderPipeline);
RenderingDevice.DrawListBindVertexArray(drawList, vertexArray);
RenderingDevice.DrawListBindIndexArray(drawList, indexArray);
RenderingDevice.DrawListSetPushConstant(drawList, renderPushConstantBytes, (uint)renderPushConstantBytes.Length);
RenderingDevice.DrawListDraw(drawList, true, 2);
RenderingDevice.DrawListEnd();
RenderingDevice.DrawCommandEndLabel();
// Unfolding into a push constant
float[] computePushConstant = [
renderSize.X, renderSize.Y, 0, 0, // Pad instead with two zeroes here
];
byte[] computePushConstantBytes = computePushConstant.ToByteArray();
// Here we draw the Underwater effect, using the waterBuffer to know where there is water geometry
RenderingDevice.DrawCommandBeginLabel("Render Underwater Effect", new Color(1f, 1f, 1f));
long computeList = RenderingDevice.ComputeListBegin();
RenderingDevice.ComputeListBindComputePipeline(computeList, computePipeline);
RenderingDevice.ComputeListBindColor(computeList, computeShader, sceneBuffers, view, 0);
RenderingDevice.ComputeListBindDepth(computeList, computeShader, sceneBuffers, view, nearestSampler, 1);
RenderingDevice.ComputeListBindImage(computeList, computeShader, waterMap, 2);
RenderingDevice.ComputeListSetPushConstant(computeList, computePushConstantBytes, (uint)computePushConstantBytes.Length);
RenderingDevice.ComputeListDispatch(computeList, xGroups, yGroups, 1);
RenderingDevice.ComputeListEnd();
RenderingDevice.DrawCommandEndLabel();
RenderingDevice.FreeRid(vertexArray);
RenderingDevice.FreeRid(indexArray);
RenderingDevice.FreeRid(waterBuffer);
}
}
protected override void ConstructBehaviour(RenderingDevice renderingDevice) {
// Framebuffer Format includes a depth attachment to Self-occlude
framebufferFormat = renderingDevice.FramebufferFormatCreate([waterMapAttachmentFormat, waterDepthAttachmentFormat]);
vertexFormat = renderingDevice.VertexFormatCreate([vertexAttribute]);
nearestSampler = renderingDevice.SamplerCreate(new() {
MinFilter = RenderingDevice.SamplerFilter.Nearest,
MagFilter = RenderingDevice.SamplerFilter.Nearest
});
ConstructRenderPipeline(renderingDevice);
ConstructComputePipeline(renderingDevice);
}
private void ConstructRenderPipeline(RenderingDevice renderingDevice) {
if (RenderShaderFile is null) return;
renderShader = renderingDevice.ShaderCreateFromSpirV(RenderShaderFile.GetSpirV());
if (! renderShader.IsValid) {
throw new ArgumentException("Render Shader is Invalid");
}
RDPipelineColorBlendState blend = new() {
Attachments = [new RDPipelineColorBlendStateAttachment()]
};
renderPipeline = renderingDevice.RenderPipelineCreate(
renderShader,
framebufferFormat,
vertexFormat,
RenderingDevice.RenderPrimitive.Triangles,
new RDPipelineRasterizationState(),
new RDPipelineMultisampleState(),
new RDPipelineDepthStencilState() {
// Enable Self-occlusion via Depth Test (see ConstructBehaviour(RenderingDevice))
EnableDepthTest = true,
EnableDepthWrite = true,
DepthCompareOperator = RenderingDevice.CompareOperator.LessOrEqual
},
blend
);
if (! renderPipeline.IsValid) {
throw new ArgumentException("Render Pipeline is Invalid");
}
}
private void ConstructComputePipeline(RenderingDevice renderingDevice) {
if (ComputeShaderFile is null) return;
computeShader = renderingDevice.ShaderCreateFromSpirV(ComputeShaderFile.GetSpirV());
if (! computeShader.IsValid) {
throw new ArgumentException("Compute Shader is Invalid");
}
computePipeline = renderingDevice.ComputePipelineCreate(computeShader);
if (! computePipeline.IsValid) {
throw new ArgumentException("Compute Pipeline is Invalid");
}
}
protected override void DestructBehaviour(RenderingDevice renderingDevice) {
if (nearestSampler.IsValid) {
renderingDevice.FreeRid(nearestSampler);
}
if (renderShader.IsValid) {
renderingDevice.FreeRid(renderShader);
renderShader = default;
}
// Don't need to free the pipeline as freeing the shader does that for us.
renderPipeline = default;
if (computeShader.IsValid) {
renderingDevice.FreeRid(computeShader);
computeShader = default;
}
// Same as above
computePipeline = default;
}
}
Render Shader:
#[vertex]
#version 450 core
layout(location = 0) in vec3 vertex;
layout(push_constant, std430) uniform Params {
mat4 world_to_clip; // World-space -> Clip-space Matrix to transform the mesh
vec3 eye_offset; // Eye offset from Multi-view
} params;
void main()
{
vec4 pos = params.world_to_clip * vec4(vertex, 1.0);
gl_Position = vec4(vec3(pos.x, -pos.y, pos.z) + params.eye_offset, pos.w);
}
#[fragment]
#version 450 core
layout(location = 0) out vec4 frag_color;
void main() {
frag_color = vec4(gl_FragCoord.xyw, 1 - float(gl_FrontFacing));
}
Compute Shader:
#[compute]
#version 450
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
layout(rgba8, set = 0, binding = 0) uniform image2D color_image; // Color of the screen pre post-processing
layout(set = 1, binding = 0) uniform sampler2D depth_image; // Depth buffer of the screen
layout(r16f, set = 2, binding = 0) uniform image2D water_map_image; // Water Map that was rendered in the Render Shader
layout(push_constant, std430) uniform Params {
vec2 render_size; // Size of the Screen
} params;
void main() {
ivec2 uv = ivec2(gl_GlobalInvocationID.xy);
vec2 depth_uv = vec2(uv) / params.render_size;
vec4 color = imageLoad(color_image, uv);
vec4 water_map = imageLoad(water_map_image, uv);
float depth = texture(depth_image, depth_uv).r;
// The amount of water we are looking through is either the end of the water volume (water_map.z) or the closest surface (depth)
float max_depth = max(water_map.z, depth);
// Actual water color calculation
vec4 water_color = mix(color, vec4(0, 0.1, 0.75, 1), 1 - max_depth);
imageStore(color_image, uv, mix(color, water_color, water_map.a));
}