Hardware-rasterized particle rendering into the G-buffer with self-shadowing via voxel grid DDA.
#include "newtype/plugin/PointCloud.h"
#include "newtype/core/Pipeline.h"
// Create a point cloud with up to 1M particles
auto cloud = std::make_unique<newtype::plugin::PointCloud>(
NewTypeRenderer::device(),
1'000'000u, // max particles
materialId, // material index (must be added to Pipeline first)
float3{-100.f, -10.f, -100.f}, // bounds_min (world space)
float3{ 100.f, 200.f, 100.f}, // bounds_max (world space)
256u // voxel grid resolution
);
_cloud = cloud.get();
mPipeline->addFeature(std::move(cloud)); // must be added BEFORE buildScene()
mPipeline->buildScene(); // queries IVoxelGridUser config from PointCloud
// Each frame — upload particle data
luisa::vector<luisa::float4> positions(count); // xyz = world pos, w = particle size
luisa::vector<luisa::float3> velocities(count);
// ... fill positions/velocities ...
_cloud->upload_positions(stream, positions, velocities, count);Pipeline::buildScene()
└─ discovers IVoxelGridUser features → creates VoxelGrid with correct bounds
Each frame:
PointCloud::onExecute()
├─ VoxelGrid::voxelize(positions) on main COMPUTE stream
├─ synchronize()
├─ RasterStream: clear RTVs + depth buffer
├─ RasterStream: draw instanced billboards (1 quad × N particles)
└─ RasterStream: merge shader → write into G-buffer (depth, vis, bary)
| Bits | Field |
|---|---|
.x |
material_id (passed as pixel shader argument) |
.y bit 31 |
glass flag (0 for points) |
.y bit 30 |
is_point flag (1 for points) |
.y bits 0-29 |
particle_id |
The shade shader detects is_point and does an early return with material albedo, bypassing instance_buffer lookup (which would be out-of-bounds since inst_id = material_id, not a real instance index).
The DSL doesn't expose discard as a named function, but you can call it via FunctionBuilder:
#include <luisa/ast/function_builder.h>
#include <luisa/ast/op.h>
// In pixel shader lambda:
Float2 corner_uv = vary.corner_uv;
Float dist_sq = corner_uv.x * corner_uv.x + corner_uv.y * corner_uv.y;
$if(dist_sq > 1.0f) {
luisa::compute::detail::FunctionBuilder::current()->call(
luisa::compute::CallOp::RASTER_DISCARD, {});
};corner_uv is in [-1, +1] — discarding outside length > 1 trims the square billboard to a circle.
The billboard quad can be replaced with any mesh (e.g., low-poly sphere):
_meshFormat = {};
VertexAttribute attrs[] = {
{VertexAttributeType::Position, PixelFormat::RGBA32F},
{VertexAttributeType::Normal, PixelFormat::RGBA32F},
{VertexAttributeType::UV0, PixelFormat::RG32F},
};
_meshFormat.emplace_vertex_stream(attrs);// Instead of 6 billboard quad verts, fill with your mesh vertices
// Vertex stride must match sum of attribute sizes in MeshFormat
_meshVB = device.create_buffer<VertexType>(vertexCount);
// ... upload mesh data ...VertexBufferView vbv{_meshVB};
RasterMesh mesh(
luisa::span<const VertexBufferView, 1>{&vbv, 1},
meshVertexCount, // was 6 for quad
_particleCount, // instance count (unchanged)
0u
);For indexed meshes, add an index buffer as a second vertex stream view.
The vertex shader reads mesh vertex positions instead of billboard corner offsets:
Float4 particle = particles.read(app.instance_id);
Float3 center = particle.xyz();
Float size = particle.w;
Float3 world_pos = center + app.position.xyz() * size; // mesh vertex offset × sizeVertexAttributeType |
HLSL Semantic | Index |
|---|---|---|
Position |
POSITION |
0 |
Normal |
NORMAL |
0 |
Tangent |
TANGENT |
0 |
Color |
COLOR |
0 |
UV0 |
TEXCOORD |
0 |
UV1 |
TEXCOORD |
1 |
UV2 |
TEXCOORD |
2 |
UV3 |
TEXCOORD |
3 |
SV_InstanceID is system-generated by the GPU — not in the vertex buffer.
The VoxelGrid uses per-axis voxel coordinate mapping for non-cubic bounds:
cell_f = (pos - bounds_min) * resolution / (bounds_max - bounds_min)
This avoids the old single-scalar cell_size (diagonal-based) which produced out-of-bounds indices for non-cubic bounds, causing GPU memory corruption that broke DI/GI passes.