Skip to content

feat: renderer: PBR, IBL, Passes - #38

Open
sp0lsh wants to merge 31 commits into
Pillware:mainfrom
sp0lsh:feat/renderer
Open

feat: renderer: PBR, IBL, Passes#38
sp0lsh wants to merge 31 commits into
Pillware:mainfrom
sp0lsh:feat/renderer

Conversation

@sp0lsh

@sp0lsh sp0lsh commented May 28, 2026

Copy link
Copy Markdown
Contributor

60k pills @ 42 FPS, macbook M1 1920x180. Single threaded yet, wasm is single threaded too.
Screenshot 2026-05-31 at 15 44 37

https://github.com/KhronosGroup/glTF-Sample-Models/tree/d7a3cc8e51d7c573771ae77a57f16b0662a905c6/2.0/MetalRoughSpheres

Screenshot 2026-05-30 at 15 42 02 Screenshot 2026-05-30 at 15 19 10

https://github.com/KhronosGroup/glTF-Sample-Models/tree/d7a3cc8e51d7c573771ae77a57f16b0662a905c6/2.0/DamagedHelmet

Screenshot 2026-05-31 at 03 28 31 Screenshot 2026-05-30 at 18 05 58
sequenceDiagram
    autonumber
    participant ECS as ECS · SoA storage
    participant GP as Gameplay systems
    participant RS as RenderingSystem (stateless)
    participant RN as renderer.render()
    participant PB as PassPBROpaque
    participant GPU as GPU

    note over GP,ECS: 2. Game — mutate world
    GP->>ECS: pill_particle / hero / drift write Transform[]

    note over RS,RN: 3. PostGame — stateless plumbing
    RS->>ECS: borrow active scene
    RS->>RN: render(active_camera, scene, delta_time, resources)
    RN->>RN: get_current_texture → create_view → create_command_encoder
    RN->>RN: WorldQuery::new(active_camera, scene, delta_time, resources)

    note over RN,GPU: Scene Passes — each Pass queries the world, owns its queue
    RN->>PB: draw(encoder, frame, view, &world)
    PB->>ECS: a. QUERY  world.query::<T>() → zip transform[]+pbr[], skip None
    PB->>PB: b. GROUP  find-or-create by material+mesh (cut PSO/bind switches)
    PB->>PB: c. STAGE  concat instances → buffer, assign base_offset
    PB->>GPU: d. UPLOAD  write per_draw storage (x1)
    PB->>GPU: e. PASS   begin_render_pass: Clear color=BLACK, depth=1.0
    PB->>GPU: f. ENCODE 6 groups → 6x draw_indexed (instanced, 60,001 total)
    PB->>GPU: g. BG     background sub-draw (fullscreen tri @ far plane)
    GPU->>GPU: vertex shader per instance: model = T·R·S → clip = viewProj·model
Loading
    cube                  497 KB raw   207 KB gzip
    Three.js r167 webgpu  779 KB raw   210 KB gzip
    Three.js r167 module  668 KB raw   165 KB gzip
    → cube beats both on raw, on par on gzip

                            cube
    total (final)         497.3 KB
    total (pre-opt)        1.83 MB

    --- final binary sections (post wasm-opt, no symbol names) ---
      code                379.3 KB
      data                100.1 KB
      wasm-meta            17.3 KB

    --- pre-opt symbol attribution (only place with crate info) ---
      engine total         117.5 KB
        pill_engine         87.0 KB
        pill_core           22.9 KB
        pill_web             7.6 KB
        pill_renderer          0 B    (merged into pill_engine this branch)
      game code              1.4 KB   (monitor only)
      [rust-std]           194.9 KB
      [rodata]             100.9 KB   (was ~130 KB before ASCII-case fix; −30 KB Unicode tables)
      winit                 75.0 KB
      wgpu                  41.7 KB
      hashbrown             41.9 KB
      web_sys               26.2 KB
      wasm_bindgen          10.4 KB

      stripped by wasm-opt:
        [debug:names]      491.5 KB
        [wasm-bindgen]     437.8 KB

    --- full deployment (wasm + JS glue) ---
      cube     571.8 KB   (wasm 497.3 + js 74.6)

sp0lsh added 2 commits May 28, 2026 18:29
# Conflicts:
#	engine/Cargo.toml
#	engine/pill_core/Cargo.toml
#	engine/pill_core/src/utils.rs
#	engine/pill_engine/Cargo.toml
#	engine/pill_engine/res/shaders/default_lit_fragment.wgsl
#	engine/pill_engine/res/shaders/default_unlit_fragment.wgsl
#	engine/pill_engine/src/ecs/systems/rendering_system.rs
#	engine/pill_engine/src/engine.rs
#	engine/pill_engine/src/graphics/dummy_renderer.rs
#	engine/pill_engine/src/graphics/render_queue.rs
#	engine/pill_engine/src/graphics/renderer.rs
#	engine/pill_engine/src/lib.rs
#	engine/pill_engine/src/renderer/drawers/mesh_drawer.rs
#	engine/pill_engine/src/renderer/resources/engine_parameters.rs
#	engine/pill_engine/src/renderer/resources/renderer_mesh.rs
#	engine/pill_engine/src/renderer/resources/renderer_shader.rs
#	engine/pill_engine/src/renderer/resources/renderer_texture.rs
#	engine/pill_engine/src/resources/material.rs
#	engine/pill_engine/src/resources/shader.rs
#	engine/pill_engine/src/resources/texture.rs
#	engine/pill_renderer/Cargo.toml
#	engine/pill_renderer/src/renderer.rs
#	engine/pill_renderer/src/resources/renderer_material.rs
#	engine/pill_renderer/src/resources/renderer_resource_storage.rs
# Conflicts:
#	engine/Cargo.toml
#	engine/pill_core/Cargo.toml
#	engine/pill_core/src/utils.rs
#	engine/pill_engine/Cargo.toml
#	engine/pill_engine/res/shaders/default_lit_fragment.wgsl
#	engine/pill_engine/res/shaders/default_unlit_fragment.wgsl
#	engine/pill_engine/src/ecs/systems/rendering_system.rs
#	engine/pill_engine/src/engine.rs
#	engine/pill_engine/src/graphics/dummy_renderer.rs
#	engine/pill_engine/src/graphics/render_queue.rs
#	engine/pill_engine/src/graphics/renderer.rs
#	engine/pill_engine/src/lib.rs
#	engine/pill_engine/src/renderer/drawers/mesh_drawer.rs
#	engine/pill_engine/src/renderer/resources/engine_parameters.rs
#	engine/pill_engine/src/renderer/resources/renderer_mesh.rs
#	engine/pill_engine/src/renderer/resources/renderer_shader.rs
#	engine/pill_engine/src/renderer/resources/renderer_texture.rs
#	engine/pill_engine/src/resources/material.rs
#	engine/pill_engine/src/resources/shader.rs
#	engine/pill_engine/src/resources/texture.rs
#	engine/pill_renderer/Cargo.toml
#	engine/pill_renderer/src/renderer.rs
#	engine/pill_renderer/src/resources/renderer_material.rs
#	engine/pill_renderer/src/resources/renderer_resource_storage.rs
@sp0lsh
sp0lsh marked this pull request as draft May 28, 2026 16:30
@sp0lsh sp0lsh changed the title feat: renderer feat: renderer: PBR, Passes May 29, 2026
@sp0lsh sp0lsh changed the title feat: renderer: PBR, Passes feat: renderer: PBR, IBL, Passes May 30, 2026
sp0lsh added 13 commits May 30, 2026 18:12
…kspace path

- div_ceil/repeat_n: replace manual bit-alignment arithmetic with .div_ceil() and
  std::iter::repeat_n in pass_pbr_static.rs and state.rs
- needless_range_loop: rewrite mat4_mul inner loop as (0..4).map(...).sum() in
  glb_to_cooked_mesh.rs
- egui native-only: move egui + egui-wgpu from unconditional deps to
  [target.'cfg(not(target_arch = "wasm32"))'.dependencies]; gate egui_client.rs,
  EguiClient re-export, RenderStateComponent.egui_client field, and
  init_default_passes signature/call sites with #[cfg(not(target_arch = "wasm32"))]
  to restore WASM binary size below 499 KB budget
- codesign: gate codesign_adhoc function and call sites with
  #[cfg(target_os = "macos")] so Linux CI does not try to invoke the tool
- workspace path: restore NO_PATH placeholder in engine/Cargo.toml so the
  launcher can substitute the correct game path on any machine
- pill_tunel: add egui = "0.32.1" direct dependency (game.rs uses egui:: types);
  fix workspace field to NO_PATH
}
}

fn orbit_camera_system(engine: &mut Engine) -> Result<()> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we really ought to have a common place for such systems/components

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, these are just emerging. A code that get's duplicated 3 times is moved to a proper common place.


#[cfg(target_os = "macos")]
fn codesign_adhoc(path: &PathBuf) -> Result<()> {
let status = Command::new("codesign")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does this do?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On macos executables would faild duoe to .dylib (.dll on mac) being not signed, even if it's development version #think_different


use pill_game::WebGame;
#[cfg(target_arch = "wasm32")]
#[global_allocator]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what was the allocator situation for Wasm before this allocator was added?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allocator for wasm was changed to single threaded allocator with less binary size.


[dependencies]
pill_core = {path = "../pill_core"}
glam = { version = "0.30.8", features = ["serde", "bytemuck"] }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we pulling it in here? Isn't everything typedeffed already in core?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question


/// Parse an RTEX binary header.
/// Returns `(pixel_bytes, width, height, mip_count, version)`.
fn decode_rtex_header(bytes: &[u8]) -> (&[u8], u32, u32, u32, u32) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

defo not place for this here, move to utils?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utils in what domain?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I though for now just a file in the crate - if we start getting a lot of common functions/code across the whole project then maybe a new pill_utils crate?


/// Provides equirect background texture bytes; used by PassBackground on first-frame init.
/// Must be called before the first rendered frame (i.e. from `start()`).
pub fn set_background_texture(&mut self, bytes: Vec<u8>) -> Result<()> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this also has utilish taste to it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, I think the less we require to be called by the user the better - it should all happen automagically or at least minimize it to be called inside start. I think such a requirement is too stark. The game should run if the user does not provide these textures. Also, what if the user wants to change it mid-run?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Engine is expected to have no knowledge about domain specific assets. Remove set_background_texture() and keep it as component state

pub type RenderQueueKeyType = u64;

// 64-bit render sort key layout (MSB → LSB):
// bit: 63 59 58 51 50 43 42 35 34 27 26 19 18 11 10 0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if I don't prefer the old more verbose ASCII diagram

) -> Result<()> {
let shader = engine.get_resource::<Shader>(&self.shader_handle)?;

// Get texture to be set

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure about removing these comments

#[cfg(not(target_arch = "wasm32"))]
fn new(window: Arc<winit::window::Window>, config: EngineConfig) -> Result<Self> {
info!(LogContext::Rendering => "Initializing {}", "Renderer".module_object_style());
let state = pollster::block_on(State::new(window, config))?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will this run the renderer on another thread? I am trying to grasp the point behind having it inited async

vertex_wgsl: &str,
fragment_wgsl: &str,
texture_slots: &HashMap<String, crate::resources::ShaderTextureSlot>,
parameter_slots: &[(String, crate::resources::ShaderParameterSlot)],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the other is a hashmap and this is an array? Also, maybe a custom typedef so we avoid long names?


let frame = self.state.surface.get_current_texture();
let frame = match frame {
std::result::Result::Ok(frame) => frame,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure we need to have qualification everywhere, especially on std types or pill_* ones. Defo good to have it on wgpu etc, but then again we create long chains of types and names and its hard to read. If we use it often, just use it at the top.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we need to decide on one project-wide approach.
But I'm also not sure which to pick

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the rules should be simple:
If used often - import, if it's an one-off and is not a::super::long::qualification::type::name then we can do it with the qualification. Otherwise, let's just import at the top and save the future code readers additional strain. LSPs are good enough at providing the full type info if necessary.

Ok(buffer)
}

fn create_pipeline_v2(&mut self, desc: PipelineV2Desc) -> Result<PipelineV2> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

v2? why then keep v1?

fn create_texture_from_pixels(
&mut self,
name: &str,
mip_pixels: &[&[u8]],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

quite weird type, why it's not contiguous or a separate specialized type?

module: &vertex_shader,
entry_point: Some("vs_main"),
buffers: vertex_layouts, // Specifies structure of vertices that will be passed to the vertex shader
buffers: vertex_layouts,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a lot of comments removed - sure we want that?

wgpu::VertexAttribute {
// Vertex texture coordinates
// slangc maps TEXCOORD0 → @location(4), not 1
offset: mem::size_of::<[f32; 3]>() as wgpu::BufferAddress,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comments removed

pub(crate) fn write_parameters_to_buffer(
queue: &wgpu::Queue,
buffer: &wgpu::Buffer,
parameter_slots: &[(String, ShaderParameterSlot)],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why this array of pairs?

// --- Pass API types ---

/// Read-only view of the world handed to each pass. A pass calls `query::<T>()` for whatever
/// component types it needs and builds its OWN draw list (opaque, translucent, shadow, …).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is in line with what @MattSzymonski is implementing in the ECS refactor. Might be good to commonalize the query-approach to using just a slice of mut engine.

}

#[derive(Clone, Debug)]
pub struct PipelineV2Desc<'a> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still don't get the V2 - will it be merged without a rename?

textures: &[(String, MaterialTexture)],
) -> Result<()>;
/// Returns the OS window the renderer is bound to.
fn get_window(&self) -> std::sync::Arc<winit::window::Window>;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we have a coding convention for using qualified type names? If not, it's good to set it as I don't know when we use -import a type and when to use a full qualified type name.

pub struct RenderQueueItem {
pub key: RenderQueueKey,
pub entity_index: u32,
/// Raw transform copied (no trig) during the cache-warm sequential ECS scan;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whats trig in this context? Trigonometry?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, building LocalMatrix requires computing sin/cos from angle values

entry_func: "fs_main",
},
vertex_buffers: &[
<crate::renderer::resources::RendererMesh as crate::renderer::resources::Vertex>::data_layout_descriptor(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, a bit too long

&mut self,
encoder: &mut wgpu::CommandEncoder,
renderer: &mut dyn PillRenderer,
_frame: &wgpu::SurfaceTexture,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this param?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some Passes may need surface to get it's format or size

// Read active camera and transform.
let active_camera_index = world.active_camera.data().index as usize;
let active_camera_component = camera_components
.data

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not handle the error gracefully?

let active_camera_component = camera_components
.data
.get(active_camera_index)
.unwrap()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

safer failing?

// --- Transform Component ---

// NOTE: Setting position/rotation/scale directly is not possible since we need to update matrices after each change
// 36 bytes hot (pos+rot+scale only); model_matrix/normal_matrix were dead weight — GPU

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait, so model_matrix was used only for rendering?

@sp0lsh sp0lsh Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no Scene hierarchy and transform tree, isn't there?

In this case ALU on GPU is faster than Memory Bandwidth of pushing whole matrices through CPU->GPU.

{
//entry: HashMapVacantEntry<'a, TypeId, Box<(dyn Any + Send + Sync)>>,
entry: HashMapVacantEntry<'a, TypeId, Box<dyn Any + Send>>,
entry: HashMapVacantEntry<'a, TypeId, Box<dyn Any>>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

they used to be Send + Sync now they are no longer either? Why?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we ever have threadness implemented, because that's the reason why we add these traits. If we want to have multithreaded iteration over the typemaps then we would probably need to either slice them up in non-overlapping partitions (which is cleaner) or have these traits added.

@@ -0,0 +1,9 @@
// WHY: zero-size marker types so Handle<RendererMeshTag> and Handle<RendererTextureTag> are different types — passing the wrong one is a compile error, not a runtime bug.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why

}
}

pub(crate) fn lerp3(start: [f32; 3], end: [f32; 3], factor: f32) -> [f32; 3] {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't some of these pill_assets helper functions be shared? We are starting to have a lot of math/util funcitons flying around here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, math in core looks like good place.

As per previous reply, pill_assets are based on examples using it and as code emerges the functions are moved into proper modules.

@JDuchniewicz

Copy link
Copy Markdown
Collaborator

LGTM, minor remarks. Would really appreciate some documentation at this point, especially since I am no rendering expert and it's not that easy to review it without a deeper understanding. Read through Aaltonen's presentations and at least I get the gist of the rendering optimization thanks to that 😁

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants