@bornengine/engine 0.4.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/native/android/Cargo.lock +1848 -0
- package/native/android/Cargo.toml +24 -0
- package/native/android/src/lib.rs +702 -0
- package/native/ios/Cargo.lock +1690 -0
- package/native/ios/Cargo.toml +32 -0
- package/native/ios/src/lib.rs +1267 -0
- package/native/linux/Cargo.lock +3279 -0
- package/native/linux/Cargo.toml +29 -0
- package/native/linux/src/lib.rs +1331 -0
- package/native/macos/Cargo.lock +3310 -0
- package/native/macos/Cargo.toml +46 -0
- package/native/macos/src/lib.rs +1302 -0
- package/native/shared/Cargo.lock +1899 -0
- package/native/shared/Cargo.toml +62 -0
- package/native/shared/assets/default_font.ttf +0 -0
- package/native/shared/build.rs +270 -0
- package/native/shared/shaders/common/clouds.wgsl +122 -0
- package/native/shared/shaders/common/fog.wgsl +16 -0
- package/native/shared/shaders/common/foliage_wind.wgsl +98 -0
- package/native/shared/shaders/common/imposter.wgsl +112 -0
- package/native/shared/shaders/common/pbr.wgsl +186 -0
- package/native/shared/shaders/common/shadows.wgsl +186 -0
- package/native/shared/shaders/common/sky.wgsl +8 -0
- package/native/shared/shaders/common/tonemap.wgsl +25 -0
- package/native/shared/shaders/impulse_field.wgsl +57 -0
- package/native/shared/shaders/material_abi.wgsl +383 -0
- package/native/shared/shaders/materials/test_minimal.wgsl +42 -0
- package/native/shared/src/anim_mixer.rs +61 -0
- package/native/shared/src/attach.rs +263 -0
- package/native/shared/src/audio/decode.rs +123 -0
- package/native/shared/src/audio/mod.rs +863 -0
- package/native/shared/src/audio/render.rs +892 -0
- package/native/shared/src/audio/spsc.rs +156 -0
- package/native/shared/src/audio/stream.rs +226 -0
- package/native/shared/src/custom_shaders.rs +104 -0
- package/native/shared/src/decals.rs +245 -0
- package/native/shared/src/drs.rs +211 -0
- package/native/shared/src/engine.rs +261 -0
- package/native/shared/src/ffi.rs +116 -0
- package/native/shared/src/ffi_core/assets.rs +388 -0
- package/native/shared/src/ffi_core/audio_ffi.rs +184 -0
- package/native/shared/src/ffi_core/draw.rs +334 -0
- package/native/shared/src/ffi_core/game_loop.rs +577 -0
- package/native/shared/src/ffi_core/input.rs +234 -0
- package/native/shared/src/ffi_core/mod.rs +127 -0
- package/native/shared/src/ffi_core/models.rs +1154 -0
- package/native/shared/src/ffi_core/ragdoll_ffi.rs +261 -0
- package/native/shared/src/ffi_core/scene.rs +626 -0
- package/native/shared/src/ffi_core/vfx.rs +212 -0
- package/native/shared/src/ffi_core/visual.rs +691 -0
- package/native/shared/src/frame_callbacks.rs +122 -0
- package/native/shared/src/geometry.rs +236 -0
- package/native/shared/src/handles.rs +182 -0
- package/native/shared/src/input.rs +448 -0
- package/native/shared/src/jolt_sys.rs +822 -0
- package/native/shared/src/lib.rs +55 -0
- package/native/shared/src/models.rs +1093 -0
- package/native/shared/src/models_gltf.rs +1280 -0
- package/native/shared/src/particles.rs +391 -0
- package/native/shared/src/physics_jolt.rs +1908 -0
- package/native/shared/src/picking.rs +298 -0
- package/native/shared/src/postfx.rs +345 -0
- package/native/shared/src/profiler.rs +492 -0
- package/native/shared/src/ragdoll.rs +474 -0
- package/native/shared/src/renderer/atmosphere_lut.rs +573 -0
- package/native/shared/src/renderer/brdf_lut.rs +154 -0
- package/native/shared/src/renderer/draw2d.rs +143 -0
- package/native/shared/src/renderer/formats.rs +822 -0
- package/native/shared/src/renderer/froxel.rs +421 -0
- package/native/shared/src/renderer/gi_bake.rs +653 -0
- package/native/shared/src/renderer/graph.rs +462 -0
- package/native/shared/src/renderer/hiz.rs +269 -0
- package/native/shared/src/renderer/hot_reload.rs +390 -0
- package/native/shared/src/renderer/impulse_field.rs +456 -0
- package/native/shared/src/renderer/lighting.rs +154 -0
- package/native/shared/src/renderer/material_instancing.rs +171 -0
- package/native/shared/src/renderer/material_pipeline.rs +700 -0
- package/native/shared/src/renderer/material_system.rs +1996 -0
- package/native/shared/src/renderer/material_system_tests.rs +601 -0
- package/native/shared/src/renderer/material_system_wasm.rs +41 -0
- package/native/shared/src/renderer/mod.rs +12556 -0
- package/native/shared/src/renderer/model_draw.rs +641 -0
- package/native/shared/src/renderer/occlusion.rs +429 -0
- package/native/shared/src/renderer/planar_pass.rs +593 -0
- package/native/shared/src/renderer/planar_reflection.rs +499 -0
- package/native/shared/src/renderer/post_pass.rs +249 -0
- package/native/shared/src/renderer/postfx_chain.rs +728 -0
- package/native/shared/src/renderer/pt_pass.rs +577 -0
- package/native/shared/src/renderer/scene_pass.rs +607 -0
- package/native/shared/src/renderer/shader_include.rs +205 -0
- package/native/shared/src/renderer/shader_library.rs +135 -0
- package/native/shared/src/renderer/shaders/ao.rs +570 -0
- package/native/shared/src/renderer/shaders/core.rs +1243 -0
- package/native/shared/src/renderer/shaders/env.rs +907 -0
- package/native/shared/src/renderer/shaders/gi.rs +810 -0
- package/native/shared/src/renderer/shaders/mod.rs +19 -0
- package/native/shared/src/renderer/shaders/post.rs +1558 -0
- package/native/shared/src/renderer/shaders/pt.rs +1859 -0
- package/native/shared/src/renderer/shaders/ssgi.rs +1586 -0
- package/native/shared/src/renderer/shadow_pass.rs +731 -0
- package/native/shared/src/renderer/ssgi_pass.rs +392 -0
- package/native/shared/src/renderer/ssr_pass.rs +188 -0
- package/native/shared/src/renderer/texture_store.rs +473 -0
- package/native/shared/src/renderer/transient.rs +591 -0
- package/native/shared/src/renderer/types.rs +941 -0
- package/native/shared/src/renderer/util.rs +152 -0
- package/native/shared/src/scene.rs +1362 -0
- package/native/shared/src/sdf_cache.rs +274 -0
- package/native/shared/src/shadows.rs +1036 -0
- package/native/shared/src/staging.rs +102 -0
- package/native/shared/src/string_header.rs +266 -0
- package/native/shared/src/text_renderer.rs +502 -0
- package/native/shared/src/textures.rs +197 -0
- package/native/tvos/Cargo.lock +1693 -0
- package/native/tvos/Cargo.toml +36 -0
- package/native/tvos/metal-patched/Cargo.toml +178 -0
- package/native/tvos/metal-patched/LICENSE-APACHE +201 -0
- package/native/tvos/metal-patched/LICENSE-MIT +25 -0
- package/native/tvos/metal-patched/src/acceleration_structure.rs +667 -0
- package/native/tvos/metal-patched/src/acceleration_structure_pass.rs +108 -0
- package/native/tvos/metal-patched/src/argument.rs +366 -0
- package/native/tvos/metal-patched/src/blitpass.rs +102 -0
- package/native/tvos/metal-patched/src/buffer.rs +71 -0
- package/native/tvos/metal-patched/src/capturedescriptor.rs +76 -0
- package/native/tvos/metal-patched/src/capturemanager.rs +113 -0
- package/native/tvos/metal-patched/src/commandbuffer.rs +192 -0
- package/native/tvos/metal-patched/src/commandqueue.rs +44 -0
- package/native/tvos/metal-patched/src/computepass.rs +107 -0
- package/native/tvos/metal-patched/src/constants.rs +152 -0
- package/native/tvos/metal-patched/src/counters.rs +119 -0
- package/native/tvos/metal-patched/src/depthstencil.rs +190 -0
- package/native/tvos/metal-patched/src/device.rs +2134 -0
- package/native/tvos/metal-patched/src/drawable.rs +39 -0
- package/native/tvos/metal-patched/src/encoder.rs +2041 -0
- package/native/tvos/metal-patched/src/heap.rs +281 -0
- package/native/tvos/metal-patched/src/indirect_encoder.rs +344 -0
- package/native/tvos/metal-patched/src/lib.rs +657 -0
- package/native/tvos/metal-patched/src/library.rs +902 -0
- package/native/tvos/metal-patched/src/mps.rs +575 -0
- package/native/tvos/metal-patched/src/pipeline/compute.rs +475 -0
- package/native/tvos/metal-patched/src/pipeline/mod.rs +71 -0
- package/native/tvos/metal-patched/src/pipeline/render.rs +762 -0
- package/native/tvos/metal-patched/src/renderpass.rs +443 -0
- package/native/tvos/metal-patched/src/resource.rs +182 -0
- package/native/tvos/metal-patched/src/sampler.rs +165 -0
- package/native/tvos/metal-patched/src/sync.rs +178 -0
- package/native/tvos/metal-patched/src/texture.rs +352 -0
- package/native/tvos/metal-patched/src/types.rs +90 -0
- package/native/tvos/metal-patched/src/vertexdescriptor.rs +250 -0
- package/native/tvos/src/audio_backend.rs +197 -0
- package/native/tvos/src/lib.rs +1891 -0
- package/native/visionos/Cargo.lock +1693 -0
- package/native/visionos/Cargo.toml +40 -0
- package/native/visionos/src/audio_backend.rs +197 -0
- package/native/visionos/src/lib.rs +1887 -0
- package/native/watchos/Cargo.lock +16 -0
- package/native/watchos/Cargo.toml +19 -0
- package/native/watchos/shaders/bloom_postfx.metal +99 -0
- package/native/watchos/src/BloomWatchApp.swift +1267 -0
- package/native/watchos/src/BloomWatchAudio.swift +179 -0
- package/native/watchos/src/audio.rs +55 -0
- package/native/watchos/src/draw_list.rs +229 -0
- package/native/watchos/src/ffi_stubs.rs +915 -0
- package/native/watchos/src/ffi_stubs_manual.rs +35 -0
- package/native/watchos/src/lib.rs +1124 -0
- package/native/watchos/src/models.rs +746 -0
- package/native/watchos/src/postfx.rs +95 -0
- package/native/watchos/src/scene.rs +534 -0
- package/native/watchos/src/textures.rs +184 -0
- package/native/web/Cargo.lock +1657 -0
- package/native/web/Cargo.toml +43 -0
- package/native/web/bloom_glue.js +695 -0
- package/native/web/build.sh +131 -0
- package/native/web/index.html +35 -0
- package/native/web/jolt_bridge.js +1519 -0
- package/native/web/src/input_ffi.rs +286 -0
- package/native/web/src/lib.rs +1796 -0
- package/native/web/src/material_ffi.rs +710 -0
- package/native/web/src/parity_ffi.rs +343 -0
- package/native/web/src/physics_ffi.rs +643 -0
- package/native/web/src/ragdoll_ffi.rs +250 -0
- package/native/web/src/render_settings.rs +98 -0
- package/native/windows/Cargo.lock +1815 -0
- package/native/windows/Cargo.toml +68 -0
- package/native/windows/src/lib.rs +1486 -0
- package/package.json +4279 -0
- package/src/audio/index.ts +315 -0
- package/src/core/colors.ts +63 -0
- package/src/core/index.ts +1206 -0
- package/src/core/keys.ts +63 -0
- package/src/core/types.ts +104 -0
- package/src/index.ts +171 -0
- package/src/math/index.ts +516 -0
- package/src/mobile/index.ts +294 -0
- package/src/models/index.ts +1258 -0
- package/src/physics/index.ts +1134 -0
- package/src/scene/index.ts +698 -0
- package/src/shapes/index.ts +120 -0
- package/src/text/index.ts +48 -0
- package/src/textures/index.ts +187 -0
- package/src/vfx/index.ts +191 -0
- package/src/world/index.ts +24 -0
- package/src/world/loader.ts +423 -0
- package/src/world/prefab.ts +217 -0
- package/src/world/render.ts +172 -0
- package/src/world/saver.ts +108 -0
- package/src/world/serialize.ts +301 -0
- package/src/world/terrain.ts +355 -0
- package/src/world/types.ts +160 -0
- package/src/world/validate.ts +319 -0
- package/src/world/version.ts +114 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
//! Hi-Z (hierarchical linear-depth) pyramid build: a linearize pass
|
|
2
|
+
//! into mip 0 followed by min-downsample passes. Consumed by SSAO/SSR
|
|
3
|
+
//! ray-march acceleration and (via the max-reduce in occlusion.rs) the
|
|
4
|
+
//! occlusion-culling grid. Split out of renderer/mod.rs (2000-line file
|
|
5
|
+
//! policy); pipelines and the mip chain stay fields on [`Renderer`].
|
|
6
|
+
|
|
7
|
+
use super::formats::HIZ_MIP_COUNT;
|
|
8
|
+
use super::formats::halton;
|
|
9
|
+
use super::{HizDownsampleParams, HizLinearizeParams, SsaoBlurParams, SsaoParams};
|
|
10
|
+
use super::Renderer;
|
|
11
|
+
|
|
12
|
+
impl Renderer {
|
|
13
|
+
pub(super) fn record_hiz_chain(
|
|
14
|
+
&mut self,
|
|
15
|
+
encoder: &mut wgpu::CommandEncoder,
|
|
16
|
+
profiler: &mut crate::profiler::Profiler,
|
|
17
|
+
half_w: u32,
|
|
18
|
+
half_h: u32,
|
|
19
|
+
p22: f32,
|
|
20
|
+
p32: f32,
|
|
21
|
+
) {
|
|
22
|
+
// --- Hi-Z build: linearize depth into mip 0 -----------------
|
|
23
|
+
let lin_params = HizLinearizeParams {
|
|
24
|
+
params: [1.0 / half_w as f32, 1.0 / half_h as f32, p22, p32],
|
|
25
|
+
size: [half_w, half_h, 0, 0],
|
|
26
|
+
};
|
|
27
|
+
self.queue.write_buffer(&self.hiz_linearize_uniform_buffer, 0, bytemuck::bytes_of(&lin_params));
|
|
28
|
+
if self.hiz_linearize_bg_cache.is_none() {
|
|
29
|
+
self.hiz_linearize_bg_cache = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
|
30
|
+
label: Some("hiz_linearize_bg"),
|
|
31
|
+
layout: &self.hiz_linearize_layout,
|
|
32
|
+
entries: &[
|
|
33
|
+
wgpu::BindGroupEntry { binding: 0, resource: self.hiz_linearize_uniform_buffer.as_entire_binding() },
|
|
34
|
+
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.depth_view) },
|
|
35
|
+
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.ssao_depth_sampler) },
|
|
36
|
+
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.hiz_views[0]) },
|
|
37
|
+
],
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
{
|
|
41
|
+
let bg = self.hiz_linearize_bg_cache.as_ref().unwrap();
|
|
42
|
+
let ts = profiler.compute_pass_timestamp_writes("hiz_linearize_pass");
|
|
43
|
+
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
|
44
|
+
label: Some("hiz_linearize_pass"),
|
|
45
|
+
timestamp_writes: ts,
|
|
46
|
+
});
|
|
47
|
+
pass.set_pipeline(&self.hiz_linearize_pipeline);
|
|
48
|
+
pass.set_bind_group(0, bg, &[]);
|
|
49
|
+
pass.dispatch_workgroups((half_w + 7) / 8, (half_h + 7) / 8, 1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- Hi-Z build: downsample mip i -> mip i+1 ----------------
|
|
53
|
+
for i in 0..(HIZ_MIP_COUNT - 1) as usize {
|
|
54
|
+
let dst_w = (half_w >> (i + 1)).max(1);
|
|
55
|
+
let dst_h = (half_h >> (i + 1)).max(1);
|
|
56
|
+
let ds_params = HizDownsampleParams {
|
|
57
|
+
size: [dst_w, dst_h, 0, 0],
|
|
58
|
+
};
|
|
59
|
+
self.queue.write_buffer(&self.hiz_downsample_uniform_buffers[i], 0, bytemuck::bytes_of(&ds_params));
|
|
60
|
+
if self.hiz_downsample_bg_cache[i].is_none() {
|
|
61
|
+
self.hiz_downsample_bg_cache[i] = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
|
62
|
+
label: Some("hiz_downsample_bg"),
|
|
63
|
+
layout: &self.hiz_downsample_layout,
|
|
64
|
+
entries: &[
|
|
65
|
+
wgpu::BindGroupEntry { binding: 0, resource: self.hiz_downsample_uniform_buffers[i].as_entire_binding() },
|
|
66
|
+
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.hiz_views[i]) },
|
|
67
|
+
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self.hiz_views[i + 1]) },
|
|
68
|
+
],
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
let bg = self.hiz_downsample_bg_cache[i].as_ref().unwrap();
|
|
72
|
+
let ts_label: &'static str = match i {
|
|
73
|
+
0 => "hiz_downsample_pass_1",
|
|
74
|
+
1 => "hiz_downsample_pass_2",
|
|
75
|
+
2 => "hiz_downsample_pass_3",
|
|
76
|
+
_ => "hiz_downsample_pass_4",
|
|
77
|
+
};
|
|
78
|
+
let ts = profiler.compute_pass_timestamp_writes(ts_label);
|
|
79
|
+
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
|
80
|
+
label: Some(ts_label),
|
|
81
|
+
timestamp_writes: ts,
|
|
82
|
+
});
|
|
83
|
+
pass.set_pipeline(&self.hiz_downsample_pipeline);
|
|
84
|
+
pass.set_bind_group(0, bg, &[]);
|
|
85
|
+
pass.dispatch_workgroups((dst_w + 7) / 8, (dst_h + 7) / 8, 1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
impl Renderer {
|
|
92
|
+
/// SSAO bilateral blur (depth-guided, edge-preserving) when SSAO is
|
|
93
|
+
/// on, or a white-clear of the blur RT when it's off so the
|
|
94
|
+
/// composite samples "no occlusion". Split from
|
|
95
|
+
/// end_frame_with_scene (2000-line file policy).
|
|
96
|
+
pub(super) fn record_ssao_blur(
|
|
97
|
+
&mut self,
|
|
98
|
+
encoder: &mut wgpu::CommandEncoder,
|
|
99
|
+
surf_w: u32,
|
|
100
|
+
surf_h: u32,
|
|
101
|
+
) {
|
|
102
|
+
// ============================================================
|
|
103
|
+
// SSAO bilateral blur: smooth the noisy GTAO output while
|
|
104
|
+
// preserving depth edges (depth-guided bilateral filter).
|
|
105
|
+
// Reads ssao_rt → writes ssao_blur_rt.
|
|
106
|
+
// ============================================================
|
|
107
|
+
// PT: when the path tracer owns the frame it computes real
|
|
108
|
+
// occlusion by tracing — screen-space AO on top double-darkens
|
|
109
|
+
// every crevice. Route compose to "no occlusion" (white clear
|
|
110
|
+
// below) for those frames.
|
|
111
|
+
if self.ssao_enabled && !self.pt_owns_frame() {
|
|
112
|
+
// texel_size is the size of one SSAO RT texel (half-res).
|
|
113
|
+
let ao_w = (surf_w / 2).max(1) as f32;
|
|
114
|
+
let ao_h = (surf_h / 2).max(1) as f32;
|
|
115
|
+
let bp = SsaoBlurParams {
|
|
116
|
+
params: [1.0 / ao_w, 1.0 / ao_h, 0.05, 0.0],
|
|
117
|
+
};
|
|
118
|
+
self.queue.write_buffer(&self.ssao_blur_uniform_buffer, 0, bytemuck::bytes_of(&bp));
|
|
119
|
+
|
|
120
|
+
if self.ssao_blur_bg_cache.is_none() {
|
|
121
|
+
self.ssao_blur_bg_cache = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
|
122
|
+
label: Some("ssao_blur_bg"),
|
|
123
|
+
layout: &self.ssao_blur_layout,
|
|
124
|
+
entries: &[
|
|
125
|
+
wgpu::BindGroupEntry { binding: 0, resource: self.ssao_blur_uniform_buffer.as_entire_binding() },
|
|
126
|
+
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.ssao_rt_view) },
|
|
127
|
+
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) },
|
|
128
|
+
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.depth_view) },
|
|
129
|
+
wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::Sampler(&self.ssao_depth_sampler) },
|
|
130
|
+
],
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
let bg = self.ssao_blur_bg_cache.as_ref().unwrap();
|
|
134
|
+
|
|
135
|
+
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
|
136
|
+
label: Some("ssao_blur_pass"),
|
|
137
|
+
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
|
138
|
+
view: &self.ssao_blur_rt_view,
|
|
139
|
+
resolve_target: None,
|
|
140
|
+
depth_slice: None,
|
|
141
|
+
ops: wgpu::Operations {
|
|
142
|
+
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
|
|
143
|
+
store: wgpu::StoreOp::Store,
|
|
144
|
+
},
|
|
145
|
+
})],
|
|
146
|
+
depth_stencil_attachment: None,
|
|
147
|
+
timestamp_writes: None,
|
|
148
|
+
occlusion_query_set: None,
|
|
149
|
+
multiview_mask: None,
|
|
150
|
+
});
|
|
151
|
+
pass.set_pipeline(&self.ssao_blur_pipeline);
|
|
152
|
+
pass.set_bind_group(0, bg, &[]);
|
|
153
|
+
pass.draw(0..3, 0..1);
|
|
154
|
+
} else {
|
|
155
|
+
// SSAO disabled — clear the blur RT to WHITE so the
|
|
156
|
+
// composite pass samples "no occlusion". Cheaper than a
|
|
157
|
+
// full blur pass; the clear is the only GPU work.
|
|
158
|
+
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
|
159
|
+
label: Some("ssao_blur_disabled_clear"),
|
|
160
|
+
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
|
161
|
+
view: &self.ssao_blur_rt_view,
|
|
162
|
+
resolve_target: None,
|
|
163
|
+
depth_slice: None,
|
|
164
|
+
ops: wgpu::Operations {
|
|
165
|
+
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
|
|
166
|
+
store: wgpu::StoreOp::Store,
|
|
167
|
+
},
|
|
168
|
+
})],
|
|
169
|
+
depth_stencil_attachment: None,
|
|
170
|
+
timestamp_writes: None,
|
|
171
|
+
occlusion_query_set: None,
|
|
172
|
+
multiview_mask: None,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
impl Renderer {
|
|
179
|
+
/// GTAO compute dispatch (half-res, Hi-Z-accelerated, temporal EMA
|
|
180
|
+
/// ping-pong). Caller guards on `ssao_enabled` and passes the
|
|
181
|
+
/// projection terms. Split from end_frame_with_scene.
|
|
182
|
+
#[allow(clippy::too_many_arguments)]
|
|
183
|
+
pub(super) fn record_gtao(
|
|
184
|
+
&mut self,
|
|
185
|
+
encoder: &mut wgpu::CommandEncoder,
|
|
186
|
+
profiler: &mut crate::profiler::Profiler,
|
|
187
|
+
half_w: u32,
|
|
188
|
+
half_h: u32,
|
|
189
|
+
p00: f32,
|
|
190
|
+
p11: f32,
|
|
191
|
+
p20: f32,
|
|
192
|
+
p21: f32,
|
|
193
|
+
) {
|
|
194
|
+
let p22 = self.current_proj_matrix[2][2];
|
|
195
|
+
let p32 = self.current_proj_matrix[3][2];
|
|
196
|
+
// --- SSAO (compute GTAO, samples Hi-Z pyramid) --------------
|
|
197
|
+
let ld = self.lighting_uniforms.light_dir;
|
|
198
|
+
let v = &self.current_view_matrix;
|
|
199
|
+
let light_dir_vs = [
|
|
200
|
+
v[0][0]*ld[0] + v[1][0]*ld[1] + v[2][0]*ld[2],
|
|
201
|
+
v[0][1]*ld[0] + v[1][1]*ld[1] + v[2][1]*ld[2],
|
|
202
|
+
v[0][2]*ld[0] + v[1][2]*ld[1] + v[2][2]*ld[2],
|
|
203
|
+
0.0,
|
|
204
|
+
];
|
|
205
|
+
// Temporal accumulation: ping-pong history textures.
|
|
206
|
+
// `write_idx` is the current-frame output; `read_idx` the
|
|
207
|
+
// previous frame's result. First 4 frames force alpha=1
|
|
208
|
+
// so the initial clear never contaminates the signal.
|
|
209
|
+
let write_idx = self.ssao_history_idx;
|
|
210
|
+
let read_idx = 1 - write_idx;
|
|
211
|
+
let frame_phase = self.ssao_history_frame % 4;
|
|
212
|
+
let force_refresh = if self.ssao_history_frame < 4 { 1u32 } else { 0u32 };
|
|
213
|
+
// 4-frame EMA: alpha = 1/4 = 0.25 gives equal weight to
|
|
214
|
+
// each of the 4 phases at steady state.
|
|
215
|
+
let alpha = 0.25_f32;
|
|
216
|
+
// Halton-5 rotation: uncorrelated with TAA's base-2/3 jitter
|
|
217
|
+
// so the two noise patterns don't resonate.
|
|
218
|
+
let halton5 = halton(self.ssao_history_frame + 1, 5);
|
|
219
|
+
let sp = SsaoParams {
|
|
220
|
+
params: [
|
|
221
|
+
1.0 / half_w as f32,
|
|
222
|
+
1.0 / half_h as f32,
|
|
223
|
+
self.ssao_radius,
|
|
224
|
+
self.ssao_strength,
|
|
225
|
+
],
|
|
226
|
+
proj_row01: [p00, p11, p20, p21],
|
|
227
|
+
proj_z: [p22, p32, 1.0 / p00, 1.0 / p11],
|
|
228
|
+
light_dir_vs,
|
|
229
|
+
size: [half_w, half_h, frame_phase, force_refresh],
|
|
230
|
+
temporal: [alpha, halton5, 0.0, 0.0],
|
|
231
|
+
};
|
|
232
|
+
self.queue.write_buffer(&self.ssao_uniform_buffer, 0, bytemuck::bytes_of(&sp));
|
|
233
|
+
|
|
234
|
+
if self.ssao_bg_cache[write_idx].is_none() {
|
|
235
|
+
self.ssao_bg_cache[write_idx] = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
|
236
|
+
label: Some("ssao_bg"),
|
|
237
|
+
layout: &self.ssao_layout,
|
|
238
|
+
entries: &[
|
|
239
|
+
wgpu::BindGroupEntry { binding: 0, resource: self.ssao_uniform_buffer.as_entire_binding() },
|
|
240
|
+
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&self.ssao_rt_view) },
|
|
241
|
+
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.hiz_sampler) },
|
|
242
|
+
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.hiz_views[0]) },
|
|
243
|
+
wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self.hiz_views[1]) },
|
|
244
|
+
wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&self.hiz_views[2]) },
|
|
245
|
+
wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView(&self.hiz_views[3]) },
|
|
246
|
+
wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::TextureView(&self.hiz_views[4]) },
|
|
247
|
+
wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::TextureView(&self.velocity_rt_view) },
|
|
248
|
+
wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::TextureView(&self.ssao_history_views[read_idx]) },
|
|
249
|
+
wgpu::BindGroupEntry { binding: 10, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) },
|
|
250
|
+
wgpu::BindGroupEntry { binding: 11, resource: wgpu::BindingResource::TextureView(&self.ssao_history_views[write_idx]) },
|
|
251
|
+
],
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
let bg = self.ssao_bg_cache[write_idx].as_ref().unwrap();
|
|
255
|
+
|
|
256
|
+
let ssao_ts = profiler.compute_pass_timestamp_writes("ssao_pass");
|
|
257
|
+
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
|
258
|
+
label: Some("ssao_pass"),
|
|
259
|
+
timestamp_writes: ssao_ts,
|
|
260
|
+
});
|
|
261
|
+
pass.set_pipeline(&self.ssao_pipeline);
|
|
262
|
+
pass.set_bind_group(0, bg, &[]);
|
|
263
|
+
pass.dispatch_workgroups((half_w + 7) / 8, (half_h + 7) / 8, 1);
|
|
264
|
+
|
|
265
|
+
// Flip ping-pong indices for the next frame.
|
|
266
|
+
self.ssao_history_idx = read_idx;
|
|
267
|
+
self.ssao_history_frame = self.ssao_history_frame.wrapping_add(1);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
//! Phase 6 — material WGSL hot reload.
|
|
2
|
+
//!
|
|
3
|
+
//! When a game compiles a material via `compile_material_from_file`,
|
|
4
|
+
//! the source path is registered here, the parent directory is added
|
|
5
|
+
//! to a `notify` recursive watcher, and a worker thread funnels
|
|
6
|
+
//! file-changed events into a `mpsc::Receiver` that the main thread
|
|
7
|
+
//! drains once per frame from `Renderer::end_frame_with_scene`.
|
|
8
|
+
//!
|
|
9
|
+
//! Recompilation happens on the main thread (wgpu's `Device` is
|
|
10
|
+
//! `!Send` for our purposes here): each pending event re-reads the
|
|
11
|
+
//! file from disk, runs the same `compile_material` it originally
|
|
12
|
+
//! used, and replaces the entry in `MaterialSystem::pipelines` at the
|
|
13
|
+
//! same handle index. Existing draws keep working — they look up by
|
|
14
|
+
//! handle, never by `&MaterialPipeline`.
|
|
15
|
+
//!
|
|
16
|
+
//! Failures (parse errors, validation) are logged but don't kill the
|
|
17
|
+
//! game — the previous pipeline stays bound until the file is edited
|
|
18
|
+
//! again to something valid.
|
|
19
|
+
|
|
20
|
+
use std::collections::{HashMap, HashSet};
|
|
21
|
+
use std::path::PathBuf;
|
|
22
|
+
use std::sync::mpsc::{channel, Receiver, Sender};
|
|
23
|
+
use std::sync::Mutex;
|
|
24
|
+
use std::time::{Duration, Instant};
|
|
25
|
+
|
|
26
|
+
use super::material_pipeline::{Bucket, FragmentProfile};
|
|
27
|
+
|
|
28
|
+
/// What we remember about each file-backed material so we can
|
|
29
|
+
/// rebuild it when the file changes.
|
|
30
|
+
#[derive(Clone)]
|
|
31
|
+
pub struct FileMaterialDesc {
|
|
32
|
+
pub path: PathBuf,
|
|
33
|
+
pub profile: FragmentProfile,
|
|
34
|
+
pub bucket: Bucket,
|
|
35
|
+
pub reads_scene: bool,
|
|
36
|
+
/// EN-001 — preserved across reloads so a file-backed instanced
|
|
37
|
+
/// material continues to opt into the per-instance vertex layout
|
|
38
|
+
/// after hot reload.
|
|
39
|
+
pub wants_instancing: bool,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
pub struct MaterialHotReload {
|
|
43
|
+
/// Path → handle (1-based). Multiple handles per path possible if
|
|
44
|
+
/// the same file is compiled into multiple buckets — rare, but
|
|
45
|
+
/// supported via `Vec<u32>`.
|
|
46
|
+
by_path: HashMap<PathBuf, Vec<u32>>,
|
|
47
|
+
/// Handle (1-based, used as Vec<Option<…>> idx via -1) → desc.
|
|
48
|
+
/// Indexed parallel to `MaterialSystem::pipelines`.
|
|
49
|
+
descriptors: HashMap<u32, FileMaterialDesc>,
|
|
50
|
+
/// Cross-thread channel from the watcher's worker into the main
|
|
51
|
+
/// thread's per-frame poll. Use Mutex<Receiver> because the
|
|
52
|
+
/// renderer is held behind `&mut self` from many places.
|
|
53
|
+
rx: Mutex<Receiver<PathBuf>>,
|
|
54
|
+
_tx: Sender<PathBuf>,
|
|
55
|
+
/// Holding the watcher in the struct keeps the worker alive.
|
|
56
|
+
/// `Option` because creation is fallible and we want a clean
|
|
57
|
+
/// fallback (no hot reload) if `notify` errors at startup.
|
|
58
|
+
#[cfg(all(not(target_arch = "wasm32"), feature = "hot-reload"))]
|
|
59
|
+
_watcher: Option<notify::RecommendedWatcher>,
|
|
60
|
+
/// Last-seen change time per path — `notify` fires multiple
|
|
61
|
+
/// events for one save (truncate + write + close on macOS).
|
|
62
|
+
/// Coalesce within `DEBOUNCE_WINDOW`.
|
|
63
|
+
last_event: Mutex<HashMap<PathBuf, Instant>>,
|
|
64
|
+
/// Directories already passed to `watcher.watch` — repeatedly
|
|
65
|
+
/// adding the same dir is fine but creates duplicate events.
|
|
66
|
+
#[cfg(all(not(target_arch = "wasm32"), feature = "hot-reload"))]
|
|
67
|
+
watched_dirs: Mutex<Vec<PathBuf>>,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const DEBOUNCE_WINDOW: Duration = Duration::from_millis(120);
|
|
71
|
+
|
|
72
|
+
impl MaterialHotReload {
|
|
73
|
+
pub fn new() -> Self {
|
|
74
|
+
let (tx, rx) = channel::<PathBuf>();
|
|
75
|
+
|
|
76
|
+
// Watcher is on by default — even in release builds, since
|
|
77
|
+
// our daily dev cycle uses --release for perf. Shipped game
|
|
78
|
+
// binaries can opt out two ways:
|
|
79
|
+
// - runtime: set `BLOOM_NO_HOT_RELOAD=1`, short-circuits the
|
|
80
|
+
// watcher thread (compile_material_from_file still works,
|
|
81
|
+
// drain_pending just always returns empty)
|
|
82
|
+
// - compile-time: build with `--no-default-features` (or
|
|
83
|
+
// without the `hot-reload` feature), which drops `notify`
|
|
84
|
+
// from the dep tree entirely (EN-008)
|
|
85
|
+
#[cfg(all(not(target_arch = "wasm32"), feature = "hot-reload"))]
|
|
86
|
+
let watcher = if std::env::var("BLOOM_NO_HOT_RELOAD").map(|v| v == "1").unwrap_or(false) {
|
|
87
|
+
None
|
|
88
|
+
} else {
|
|
89
|
+
use notify::{Event, EventKind};
|
|
90
|
+
let tx_clone = tx.clone();
|
|
91
|
+
let cb = move |res: Result<Event, notify::Error>| {
|
|
92
|
+
if let Ok(ev) = res {
|
|
93
|
+
if matches!(ev.kind, EventKind::Modify(_) | EventKind::Create(_)) {
|
|
94
|
+
for p in ev.paths {
|
|
95
|
+
let _ = tx_clone.send(p);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
match notify::recommended_watcher(cb) {
|
|
101
|
+
Ok(w) => Some(w),
|
|
102
|
+
Err(e) => {
|
|
103
|
+
eprintln!("[hot_reload] failed to start file watcher: {e:?}");
|
|
104
|
+
None
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
Self {
|
|
110
|
+
by_path: HashMap::new(),
|
|
111
|
+
descriptors: HashMap::new(),
|
|
112
|
+
rx: Mutex::new(rx),
|
|
113
|
+
_tx: tx,
|
|
114
|
+
#[cfg(all(not(target_arch = "wasm32"), feature = "hot-reload"))]
|
|
115
|
+
_watcher: watcher,
|
|
116
|
+
last_event: Mutex::new(HashMap::new()),
|
|
117
|
+
#[cfg(all(not(target_arch = "wasm32"), feature = "hot-reload"))]
|
|
118
|
+
watched_dirs: Mutex::new(Vec::new()),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// Called by the FFI `compile_material_from_file` after a successful
|
|
123
|
+
/// initial compile. Records the path → handle mapping and starts
|
|
124
|
+
/// watching the file's parent directory.
|
|
125
|
+
pub fn register(&mut self, handle: u32, desc: FileMaterialDesc) {
|
|
126
|
+
let path = desc.path.clone();
|
|
127
|
+
self.by_path.entry(path.clone()).or_default().push(handle);
|
|
128
|
+
self.descriptors.insert(handle, desc);
|
|
129
|
+
self.ensure_dir_watched(&path);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// Drain pending file-change events. Returns the unique set of
|
|
133
|
+
/// (handle, descriptor) pairs that need recompilation this frame.
|
|
134
|
+
/// `notify` fires multiple events per save; we coalesce within a
|
|
135
|
+
/// 120 ms window.
|
|
136
|
+
pub fn drain_pending(&self) -> Vec<(u32, FileMaterialDesc)> {
|
|
137
|
+
let mut paths: Vec<PathBuf> = Vec::new();
|
|
138
|
+
if let Ok(rx) = self.rx.lock() {
|
|
139
|
+
while let Ok(p) = rx.try_recv() {
|
|
140
|
+
paths.push(p);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if paths.is_empty() { return Vec::new(); }
|
|
144
|
+
|
|
145
|
+
let now = Instant::now();
|
|
146
|
+
let mut last = self.last_event.lock().expect("hot_reload poisoned");
|
|
147
|
+
let mut out: Vec<(u32, FileMaterialDesc)> = Vec::new();
|
|
148
|
+
let mut seen: HashSet<u32> = HashSet::new();
|
|
149
|
+
for raw in paths {
|
|
150
|
+
// Resolve canonically so the keys we matched on register
|
|
151
|
+
// line up with the events. notify on macOS sometimes
|
|
152
|
+
// returns the realpath, sometimes the symlink path.
|
|
153
|
+
let p = std::fs::canonicalize(&raw).unwrap_or(raw);
|
|
154
|
+
if let Some(t) = last.get(&p) {
|
|
155
|
+
if now.duration_since(*t) < DEBOUNCE_WINDOW { continue; }
|
|
156
|
+
}
|
|
157
|
+
last.insert(p.clone(), now);
|
|
158
|
+
if let Some(handles) = self.by_path.get(&p) {
|
|
159
|
+
for h in handles {
|
|
160
|
+
if seen.insert(*h) {
|
|
161
|
+
if let Some(d) = self.descriptors.get(h) {
|
|
162
|
+
out.push((*h, d.clone()));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// Test-only: push a path through the channel as if `notify` had
|
|
172
|
+
/// fired a Modify event. Lets unit tests exercise the drain +
|
|
173
|
+
/// debounce logic without a real file system.
|
|
174
|
+
#[cfg(test)]
|
|
175
|
+
pub fn test_inject_event(&self, path: PathBuf) {
|
|
176
|
+
let _ = self._tx.send(path);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
fn ensure_dir_watched(&mut self, path: &std::path::Path) {
|
|
180
|
+
#[cfg(all(not(target_arch = "wasm32"), feature = "hot-reload"))]
|
|
181
|
+
{
|
|
182
|
+
use notify::{RecursiveMode, Watcher};
|
|
183
|
+
let dir = match path.parent() {
|
|
184
|
+
Some(d) => match std::fs::canonicalize(d) {
|
|
185
|
+
Ok(c) => c,
|
|
186
|
+
Err(_) => return,
|
|
187
|
+
},
|
|
188
|
+
None => return,
|
|
189
|
+
};
|
|
190
|
+
let mut watched = self.watched_dirs.lock().expect("hot_reload poisoned");
|
|
191
|
+
if watched.contains(&dir) { return; }
|
|
192
|
+
if let Some(w) = self._watcher.as_mut() {
|
|
193
|
+
if let Err(e) = w.watch(&dir, RecursiveMode::NonRecursive) {
|
|
194
|
+
eprintln!("[hot_reload] failed to watch {dir:?}: {e:?}");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
watched.push(dir);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
#[cfg(any(target_arch = "wasm32", not(feature = "hot-reload")))]
|
|
201
|
+
{
|
|
202
|
+
let _ = path;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#[cfg(test)]
|
|
208
|
+
mod tests {
|
|
209
|
+
use super::*;
|
|
210
|
+
use crate::renderer::material_pipeline::{Bucket, FragmentProfile};
|
|
211
|
+
|
|
212
|
+
fn desc(path: &str) -> FileMaterialDesc {
|
|
213
|
+
FileMaterialDesc {
|
|
214
|
+
path: PathBuf::from(path),
|
|
215
|
+
profile: FragmentProfile::Translucent,
|
|
216
|
+
bucket: Bucket::Refractive,
|
|
217
|
+
reads_scene: true,
|
|
218
|
+
wants_instancing: false,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#[test]
|
|
223
|
+
fn drain_returns_registered_handle_for_event_path() {
|
|
224
|
+
let mut hr = MaterialHotReload::new();
|
|
225
|
+
let p = PathBuf::from("/tmp/bloom_test_water.wgsl");
|
|
226
|
+
hr.register(7, FileMaterialDesc {
|
|
227
|
+
path: p.clone(),
|
|
228
|
+
profile: FragmentProfile::Translucent,
|
|
229
|
+
bucket: Bucket::Refractive,
|
|
230
|
+
reads_scene: true,
|
|
231
|
+
wants_instancing: false,
|
|
232
|
+
});
|
|
233
|
+
// canonicalize() in drain_pending falls back to the raw path
|
|
234
|
+
// when the file doesn't exist, so this resolves to itself.
|
|
235
|
+
hr.test_inject_event(p.clone());
|
|
236
|
+
let pending = hr.drain_pending();
|
|
237
|
+
assert_eq!(pending.len(), 1);
|
|
238
|
+
assert_eq!(pending[0].0, 7);
|
|
239
|
+
assert_eq!(pending[0].1.path, p);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
#[test]
|
|
243
|
+
fn drain_dedups_within_debounce_window() {
|
|
244
|
+
let mut hr = MaterialHotReload::new();
|
|
245
|
+
let p = PathBuf::from("/tmp/bloom_test_dedup.wgsl");
|
|
246
|
+
hr.register(3, desc("/tmp/bloom_test_dedup.wgsl"));
|
|
247
|
+
// notify on macOS fires multiple events per save; same path
|
|
248
|
+
// 5x within the 120 ms window must collapse to one drain.
|
|
249
|
+
for _ in 0..5 {
|
|
250
|
+
hr.test_inject_event(p.clone());
|
|
251
|
+
}
|
|
252
|
+
let pending = hr.drain_pending();
|
|
253
|
+
assert_eq!(pending.len(), 1, "debounce should collapse 5 events to 1");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#[test]
|
|
257
|
+
fn drain_returns_distinct_handles_for_distinct_paths() {
|
|
258
|
+
let mut hr = MaterialHotReload::new();
|
|
259
|
+
let p1 = PathBuf::from("/tmp/bloom_test_a.wgsl");
|
|
260
|
+
let p2 = PathBuf::from("/tmp/bloom_test_b.wgsl");
|
|
261
|
+
hr.register(1, desc("/tmp/bloom_test_a.wgsl"));
|
|
262
|
+
hr.register(2, desc("/tmp/bloom_test_b.wgsl"));
|
|
263
|
+
hr.test_inject_event(p1);
|
|
264
|
+
hr.test_inject_event(p2);
|
|
265
|
+
let pending = hr.drain_pending();
|
|
266
|
+
assert_eq!(pending.len(), 2);
|
|
267
|
+
let mut handles: Vec<u32> = pending.iter().map(|(h, _)| *h).collect();
|
|
268
|
+
handles.sort();
|
|
269
|
+
assert_eq!(handles, vec![1, 2]);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#[test]
|
|
273
|
+
fn drain_ignores_unregistered_paths() {
|
|
274
|
+
let hr = MaterialHotReload::new();
|
|
275
|
+
hr.test_inject_event(PathBuf::from("/tmp/never_registered.wgsl"));
|
|
276
|
+
let pending = hr.drain_pending();
|
|
277
|
+
assert!(pending.is_empty());
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#[test]
|
|
281
|
+
fn empty_channel_drains_to_empty_vec() {
|
|
282
|
+
let hr = MaterialHotReload::new();
|
|
283
|
+
assert!(hr.drain_pending().is_empty());
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/// EN-006 — end-to-end hot-reload: register a real on-disk file,
|
|
287
|
+
/// inject an event for its canonical path, then verify
|
|
288
|
+
/// `drain_pending` surfaces the descriptor with the same handle.
|
|
289
|
+
/// Exercises the path-canonicalisation + by_path lookup that the
|
|
290
|
+
/// purely-synthetic tests above bypass (their paths don't exist on
|
|
291
|
+
/// disk, so canonicalize is a no-op fallback).
|
|
292
|
+
#[test]
|
|
293
|
+
fn end_to_end_disk_file_drain_returns_descriptor() {
|
|
294
|
+
let mut tmp = std::env::temp_dir();
|
|
295
|
+
let unique = format!(
|
|
296
|
+
"bloom_hotreload_e2e_{}_{}.wgsl",
|
|
297
|
+
std::process::id(),
|
|
298
|
+
std::time::SystemTime::now()
|
|
299
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
300
|
+
.map(|d| d.as_nanos())
|
|
301
|
+
.unwrap_or(0),
|
|
302
|
+
);
|
|
303
|
+
tmp.push(unique);
|
|
304
|
+
std::fs::write(&tmp, "// initial contents\n").expect("write tmp wgsl");
|
|
305
|
+
let canonical = std::fs::canonicalize(&tmp).expect("canonicalize tmp");
|
|
306
|
+
|
|
307
|
+
let mut hr = MaterialHotReload::new();
|
|
308
|
+
hr.register(
|
|
309
|
+
42,
|
|
310
|
+
FileMaterialDesc {
|
|
311
|
+
path: canonical.clone(),
|
|
312
|
+
profile: FragmentProfile::Translucent,
|
|
313
|
+
bucket: Bucket::Refractive,
|
|
314
|
+
reads_scene: true,
|
|
315
|
+
wants_instancing: false,
|
|
316
|
+
},
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
// Simulate a "file changed" event identical to what the notify
|
|
320
|
+
// worker would push. The drain path canonicalises the inbound
|
|
321
|
+
// event path before lookup; the registered key is already
|
|
322
|
+
// canonical, so the two line up.
|
|
323
|
+
hr.test_inject_event(canonical.clone());
|
|
324
|
+
let pending = hr.drain_pending();
|
|
325
|
+
assert_eq!(pending.len(), 1, "registered descriptor surfaced exactly once");
|
|
326
|
+
assert_eq!(pending[0].0, 42);
|
|
327
|
+
assert_eq!(pending[0].1.path, canonical);
|
|
328
|
+
assert_eq!(pending[0].1.bucket, Bucket::Refractive);
|
|
329
|
+
assert!(pending[0].1.reads_scene);
|
|
330
|
+
|
|
331
|
+
// A subsequent edit (rewrite) injected after the debounce window
|
|
332
|
+
// re-fires for the same handle. We don't actually wait the full
|
|
333
|
+
// 120 ms here — the synthetic test_inject_event path bypasses
|
|
334
|
+
// notify entirely; we just rotate the cache by clearing it
|
|
335
|
+
// through a fresh inject after manual sleep.
|
|
336
|
+
std::thread::sleep(DEBOUNCE_WINDOW + std::time::Duration::from_millis(10));
|
|
337
|
+
std::fs::write(&tmp, "// updated contents\n").expect("rewrite tmp wgsl");
|
|
338
|
+
hr.test_inject_event(canonical.clone());
|
|
339
|
+
let pending2 = hr.drain_pending();
|
|
340
|
+
assert_eq!(pending2.len(), 1, "post-debounce edit drains again");
|
|
341
|
+
assert_eq!(pending2[0].0, 42);
|
|
342
|
+
|
|
343
|
+
let _ = std::fs::remove_file(&tmp);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/// EN-006 — full notify path. Registers a file, writes to it, waits
|
|
347
|
+
/// past the debounce window, drains. Skipped by default because
|
|
348
|
+
/// macOS FSEvents has variable latency (sometimes >500 ms after
|
|
349
|
+
/// open) which makes this flaky in CI; run locally with
|
|
350
|
+
/// `cargo test --release -- --ignored real_notify_fires_after_disk_write`
|
|
351
|
+
/// to validate the end-to-end watcher wiring.
|
|
352
|
+
#[test]
|
|
353
|
+
#[ignore]
|
|
354
|
+
fn real_notify_fires_after_disk_write() {
|
|
355
|
+
let mut tmp = std::env::temp_dir();
|
|
356
|
+
let unique = format!(
|
|
357
|
+
"bloom_hotreload_notify_{}_{}.wgsl",
|
|
358
|
+
std::process::id(),
|
|
359
|
+
std::time::SystemTime::now()
|
|
360
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
361
|
+
.map(|d| d.as_nanos())
|
|
362
|
+
.unwrap_or(0),
|
|
363
|
+
);
|
|
364
|
+
tmp.push(unique);
|
|
365
|
+
std::fs::write(&tmp, "// v1\n").expect("write tmp wgsl");
|
|
366
|
+
let canonical = std::fs::canonicalize(&tmp).expect("canonicalize tmp");
|
|
367
|
+
|
|
368
|
+
let mut hr = MaterialHotReload::new();
|
|
369
|
+
hr.register(99, FileMaterialDesc {
|
|
370
|
+
path: canonical.clone(),
|
|
371
|
+
profile: FragmentProfile::Opaque,
|
|
372
|
+
bucket: Bucket::Opaque,
|
|
373
|
+
reads_scene: false,
|
|
374
|
+
wants_instancing: false,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Give the OS a moment to register the watch, then rewrite and
|
|
378
|
+
// wait past the debounce.
|
|
379
|
+
std::thread::sleep(std::time::Duration::from_millis(200));
|
|
380
|
+
std::fs::write(&tmp, "// v2\n").expect("rewrite tmp wgsl");
|
|
381
|
+
std::thread::sleep(DEBOUNCE_WINDOW + std::time::Duration::from_millis(400));
|
|
382
|
+
|
|
383
|
+
let pending = hr.drain_pending();
|
|
384
|
+
assert!(
|
|
385
|
+
pending.iter().any(|(h, _)| *h == 99),
|
|
386
|
+
"notify should have fired for {:?}", canonical,
|
|
387
|
+
);
|
|
388
|
+
let _ = std::fs::remove_file(&tmp);
|
|
389
|
+
}
|
|
390
|
+
}
|