stagecraft 1.0.0

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.
Files changed (87) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +207 -0
  4. data/Rakefile +8 -0
  5. data/docs/api.md +85 -0
  6. data/docs/tutorial.md +344 -0
  7. data/examples/01_spinning_cube.rb +13 -0
  8. data/examples/02_primitives.rb +21 -0
  9. data/examples/03_pbr_materials.rb +20 -0
  10. data/examples/04_lights.rb +22 -0
  11. data/examples/05_directional_shadow.rb +23 -0
  12. data/examples/06_transparency.rb +19 -0
  13. data/examples/07_texture.rb +22 -0
  14. data/examples/08_shader_material.rb +30 -0
  15. data/examples/09_gltf_loader.rb +24 -0
  16. data/examples/10_animation.rb +21 -0
  17. data/examples/11_scene_graph.rb +20 -0
  18. data/examples/12_orbit_controls.rb +13 -0
  19. data/examples/13_orthographic_camera.rb +18 -0
  20. data/examples/14_raycaster.rb +14 -0
  21. data/examples/15_offscreen.rb +22 -0
  22. data/examples/16_msaa.rb +13 -0
  23. data/examples/17_physics_binding.rb +22 -0
  24. data/examples/18_custom_geometry.rb +22 -0
  25. data/examples/19_imgui_stats.rb +24 -0
  26. data/examples/20_window_adapters.rb +15 -0
  27. data/examples/README.md +11 -0
  28. data/examples/support/demo.rb +49 -0
  29. data/lib/stagecraft/animation/action.rb +79 -0
  30. data/lib/stagecraft/animation/clip.rb +15 -0
  31. data/lib/stagecraft/animation/mixer.rb +97 -0
  32. data/lib/stagecraft/animation/track.rb +122 -0
  33. data/lib/stagecraft/app.rb +61 -0
  34. data/lib/stagecraft/cameras/camera.rb +15 -0
  35. data/lib/stagecraft/cameras/orthographic_camera.rb +60 -0
  36. data/lib/stagecraft/cameras/perspective_camera.rb +69 -0
  37. data/lib/stagecraft/color.rb +56 -0
  38. data/lib/stagecraft/controls/orbit_controls.rb +119 -0
  39. data/lib/stagecraft/core/attribute.rb +91 -0
  40. data/lib/stagecraft/core/bounding.rb +131 -0
  41. data/lib/stagecraft/core/geometry.rb +155 -0
  42. data/lib/stagecraft/core/mesh.rb +26 -0
  43. data/lib/stagecraft/core/node.rb +163 -0
  44. data/lib/stagecraft/core/observed_value.rb +182 -0
  45. data/lib/stagecraft/core/raycaster.rb +62 -0
  46. data/lib/stagecraft/core/scene.rb +11 -0
  47. data/lib/stagecraft/core/skin.rb +23 -0
  48. data/lib/stagecraft/geometries.rb +200 -0
  49. data/lib/stagecraft/lights/ambient_light.rb +10 -0
  50. data/lib/stagecraft/lights/directional_light.rb +31 -0
  51. data/lib/stagecraft/lights/light.rb +30 -0
  52. data/lib/stagecraft/lights/point_light.rb +23 -0
  53. data/lib/stagecraft/lights/spot_light.rb +36 -0
  54. data/lib/stagecraft/loaders/gltf_loader.rb +387 -0
  55. data/lib/stagecraft/materials/material.rb +58 -0
  56. data/lib/stagecraft/materials/pbr_material.rb +75 -0
  57. data/lib/stagecraft/materials/shader_material.rb +21 -0
  58. data/lib/stagecraft/materials/unlit_material.rb +26 -0
  59. data/lib/stagecraft/physics_binding.rb +63 -0
  60. data/lib/stagecraft/renderer/features.rb +58 -0
  61. data/lib/stagecraft/renderer/frame.rb +45 -0
  62. data/lib/stagecraft/renderer/frame_resources.rb +467 -0
  63. data/lib/stagecraft/renderer/gpu_context.rb +120 -0
  64. data/lib/stagecraft/renderer/pipeline_cache.rb +59 -0
  65. data/lib/stagecraft/renderer/pipeline_factory.rb +470 -0
  66. data/lib/stagecraft/renderer/render_list.rb +80 -0
  67. data/lib/stagecraft/renderer/renderer.rb +336 -0
  68. data/lib/stagecraft/renderer/resource_cache.rb +176 -0
  69. data/lib/stagecraft/renderer/shaders/common.wgsl +48 -0
  70. data/lib/stagecraft/renderer/shaders/pbr.wgsl +251 -0
  71. data/lib/stagecraft/renderer/shaders/shadow.wgsl +37 -0
  72. data/lib/stagecraft/renderer/shaders/skinning.wgsl +6 -0
  73. data/lib/stagecraft/renderer/shaders/tonemap.wgsl +37 -0
  74. data/lib/stagecraft/renderer/shaders/unlit.wgsl +89 -0
  75. data/lib/stagecraft/renderer/shaders.rb +64 -0
  76. data/lib/stagecraft/renderer/stats.rb +41 -0
  77. data/lib/stagecraft/renderer/uniform_packer.rb +91 -0
  78. data/lib/stagecraft/renderer/wgpu_compatibility.rb +41 -0
  79. data/lib/stagecraft/textures/cube_texture.rb +16 -0
  80. data/lib/stagecraft/textures/sampler_state.rb +58 -0
  81. data/lib/stagecraft/textures/texture.rb +71 -0
  82. data/lib/stagecraft/version.rb +5 -0
  83. data/lib/stagecraft/window/adapter.rb +38 -0
  84. data/lib/stagecraft/window/glfw.rb +67 -0
  85. data/lib/stagecraft/window/sdl3.rb +57 -0
  86. data/lib/stagecraft.rb +60 -0
  87. metadata +187 -0
@@ -0,0 +1,336 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ class Renderer
5
+ attr_reader :device, :queue, :surface_format, :stats, :width, :height, :sample_count
6
+
7
+ def self.offscreen(width:, height:, msaa: 1, **options)
8
+ new(width:, height:, msaa:, **options)
9
+ end
10
+
11
+ def initialize(window: nil, width: 800, height: 600, msaa: 4, device: nil, queue: nil,
12
+ surface: nil, backend: nil)
13
+ @backend = backend
14
+ @window = window
15
+ @width = Integer(width)
16
+ @height = Integer(height)
17
+ @sample_count = Integer(msaa)
18
+ @stats = Stats.new
19
+ @after_render_callbacks = []
20
+ @disposed = false
21
+ @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
22
+ return if backend
23
+
24
+ @context = GPUContext.new(window:, width:, height:, device:, queue:, surface:)
25
+ @device = @context.device
26
+ @queue = @context.queue
27
+ @surface_format = @context.surface_format
28
+ @resources = FrameResources.new(context: @context, stats:, sample_count:)
29
+ @sample_count = @resources.sample_count
30
+ @resource_cache = ResourceCache.new(device: @device, queue: @queue, stats:)
31
+ @pipelines = PipelineFactory.new(
32
+ device: @device, queue: @queue, resources: @resources, resource_cache: @resource_cache,
33
+ stats:, sample_count: @sample_count
34
+ )
35
+ create_post_resources
36
+ rescue StandardError
37
+ dispose_after_failed_initialization
38
+ raise
39
+ end
40
+
41
+ def render(scene, camera)
42
+ ensure_alive!
43
+ resize_from_window
44
+ list = RenderList.new(scene, camera)
45
+ update_stats(list)
46
+ if @backend
47
+ frame = record_frame(list, camera)
48
+ report_debug_stats
49
+ return frame
50
+ end
51
+
52
+ light, light_vp = shadow_light_and_matrix(list)
53
+ @resources.resize_shadow(light.shadow.map_size) if light
54
+ ordered_items = list.items
55
+ @resources.begin_frame(
56
+ ordered_items,
57
+ camera:,
58
+ ambient: list.ambient,
59
+ lights: prioritized_lights(list.lights, light),
60
+ light_vp:,
61
+ time: elapsed_time
62
+ )
63
+ encoder = device.create_command_encoder(label: "stagecraft frame")
64
+ encode_shadow_pass(encoder, light ? ordered_items : [])
65
+ encode_main_pass(encoder, ordered_items, scene)
66
+ @after_render_callbacks.each { |callback| callback.call(encoder, @resources.hdr_view) }
67
+ target_texture, target_view = @resources.frame_target
68
+ encode_post_pass(encoder, target_view)
69
+ queue.submit([encoder.finish])
70
+ @context.present
71
+ @resource_cache.advance_frame
72
+ frame = Frame.new(
73
+ texture: target_texture,
74
+ width:,
75
+ height:,
76
+ device:,
77
+ queue:,
78
+ readable: @context.surface.nil?
79
+ )
80
+ report_debug_stats
81
+ frame
82
+ end
83
+
84
+ def resize(width, height)
85
+ ensure_alive!
86
+ @width = [Integer(width), 1].max
87
+ @height = [Integer(height), 1].max
88
+ return self if @backend
89
+ return self unless @context.resize(@width, @height)
90
+
91
+ @resources.resize(@width, @height)
92
+ create_post_resources
93
+ self
94
+ end
95
+
96
+ def on_after_render(&block)
97
+ @after_render_callbacks << block
98
+ self
99
+ end
100
+
101
+ def dispose
102
+ return self if @disposed
103
+
104
+ @disposed = true
105
+ first_error = nil
106
+ [
107
+ -> { warn_live_resources },
108
+ -> { release_post_resources },
109
+ -> { @pipelines&.dispose },
110
+ -> { @resource_cache&.dispose_all },
111
+ -> { @resources&.dispose },
112
+ -> { @context&.dispose }
113
+ ].each do |cleanup|
114
+ cleanup.call
115
+ rescue StandardError => error
116
+ first_error ||= error
117
+ end
118
+ raise first_error if first_error
119
+
120
+ self
121
+ end
122
+
123
+ def disposed?
124
+ @disposed
125
+ end
126
+
127
+ private
128
+
129
+ def dispose_after_failed_initialization
130
+ dispose
131
+ rescue StandardError
132
+ nil
133
+ end
134
+
135
+ def warn_live_resources
136
+ return unless ENV["STAGECRAFT_DEBUG"] == "1"
137
+ return unless @resource_cache&.live_count&.positive?
138
+
139
+ warn "Stagecraft renderer disposed with #{@resource_cache.live_count} live CPU resources"
140
+ end
141
+
142
+ def resize_from_window
143
+ return unless @window&.respond_to?(:drawable_size)
144
+
145
+ next_width, next_height = @window.drawable_size
146
+ resize(next_width, next_height) if next_width.positive? && next_height.positive?
147
+ end
148
+
149
+ def update_stats(list)
150
+ stats.reset_frame!
151
+ stats.increment(:visible_objects, list.items.length)
152
+ stats.increment(:culled_objects, list.culled_count)
153
+ end
154
+
155
+ def record_frame(list, camera)
156
+ @backend.begin_frame(camera:) if @backend.respond_to?(:begin_frame)
157
+ list.items.each do |item|
158
+ @backend.draw(
159
+ mesh: item.mesh,
160
+ features: item.features,
161
+ depth: item.depth,
162
+ pipeline_key: [
163
+ item.mesh.material.class,
164
+ Features.bits(item.features),
165
+ Features.vertex_layout_id(item.mesh.geometry)
166
+ ]
167
+ )
168
+ track_draw(item.mesh.geometry)
169
+ end
170
+ @backend.end_frame if @backend.respond_to?(:end_frame)
171
+ nil
172
+ end
173
+
174
+ def shadow_light_and_matrix(list)
175
+ light = list.lights.find { |candidate| candidate.is_a?(Lights::Directional) && candidate.cast_shadow }
176
+ return [nil, Larb::Mat4.identity] unless light
177
+
178
+ config = light.shadow
179
+ eye = light.world_position
180
+ target = eye + light.direction
181
+ view = Larb::Mat4.look_at(eye, target, Larb::Vec3.up)
182
+ extent = config.extent
183
+ projection = Cameras::Orthographic.new(
184
+ left: -extent, right: extent, top: extent, bottom: -extent,
185
+ near: config.near, far: config.far
186
+ ).projection_matrix
187
+ [light, projection * view]
188
+ end
189
+
190
+ def prioritized_lights(lights, shadow_light)
191
+ return lights unless shadow_light
192
+
193
+ [shadow_light, *lights.reject { |light| light.equal?(shadow_light) }]
194
+ end
195
+
196
+ def encode_shadow_pass(encoder, items)
197
+ pass = encoder.begin_render_pass(
198
+ label: "stagecraft shadow",
199
+ color_attachments: [],
200
+ depth_stencil_attachment: {
201
+ view: @resources.shadow_view,
202
+ depth_load_op: :clear,
203
+ depth_store_op: :store,
204
+ depth_clear_value: 1.0
205
+ }
206
+ )
207
+ items.each_with_index do |item, index|
208
+ next unless item.mesh.cast_shadow
209
+
210
+ draw_item(pass, item, index, shadow: true)
211
+ end
212
+ pass.end_pass
213
+ end
214
+
215
+ def encode_main_pass(encoder, items, scene)
216
+ clear = scene.background || Color.new(0.0)
217
+ attachment = @resources.main_color_attachment.merge(
218
+ clear_value: { r: clear.r, g: clear.g, b: clear.b, a: clear.a }
219
+ )
220
+ pass = encoder.begin_render_pass(
221
+ label: "stagecraft main",
222
+ color_attachments: [attachment],
223
+ depth_stencil_attachment: {
224
+ view: @resources.depth_view,
225
+ depth_load_op: :clear,
226
+ depth_store_op: :store,
227
+ depth_clear_value: 1.0
228
+ }
229
+ )
230
+ items.each_with_index { |item, index| draw_item(pass, item, index) }
231
+ pass.end_pass
232
+ end
233
+
234
+ def draw_item(pass, item, index, shadow: false)
235
+ geometry = @resource_cache.gpu_geometry(item.mesh.geometry)
236
+ pass.set_pipeline(@pipelines.pipeline_for(item, shadow:))
237
+ pass.set_bind_group(0, shadow ? @resources.shadow_frame_group : @resources.frame_group)
238
+ if shadow
239
+ pass.set_bind_group(1, @resources.empty_group)
240
+ else
241
+ pass.set_bind_group(1, @pipelines.material_binding(item.mesh.material, item.features))
242
+ end
243
+ pass.set_bind_group(
244
+ 2,
245
+ @resources.object_group_for(item.mesh),
246
+ dynamic_offsets: [@resources.object_offset(index)]
247
+ )
248
+ bind_vertex_buffers(pass, item.mesh.geometry, geometry, item.mesh.material, shadow:)
249
+ if geometry.index_buffer
250
+ pass.set_index_buffer(geometry.index_buffer, geometry.index_format)
251
+ pass.draw_indexed(geometry.index_count)
252
+ else
253
+ pass.draw(geometry.vertex_count)
254
+ end
255
+ track_draw(item.mesh.geometry) unless shadow
256
+ end
257
+
258
+ def bind_vertex_buffers(pass, geometry, gpu_geometry, material, shadow:)
259
+ names = if shadow
260
+ %i[position joints weights]
261
+ elsif material.is_a?(Materials::Unlit)
262
+ %i[position uv uv1 color joints weights]
263
+ else
264
+ PipelineFactory::ATTRIBUTE_LOCATIONS.keys
265
+ end
266
+ slot = 0
267
+ names.each do |name|
268
+ next unless geometry.attribute(name)
269
+
270
+ pass.set_vertex_buffer(slot, gpu_geometry.vertex_buffers.fetch(name))
271
+ slot += 1
272
+ end
273
+ end
274
+
275
+ def track_draw(geometry)
276
+ stats.increment(:draw_calls)
277
+ count = geometry.index&.count || geometry.attribute(:position).count
278
+ triangles = case geometry.topology
279
+ when :triangle_list then count / 3
280
+ when :triangle_strip then [count - 2, 0].max
281
+ else 0
282
+ end
283
+ stats.increment(:triangles, triangles)
284
+ end
285
+
286
+ def create_post_resources
287
+ return unless @resources
288
+
289
+ release_post_resources
290
+ @post_sampler = device.create_sampler(mag_filter: :linear, min_filter: :linear)
291
+ @post_group = device.create_bind_group(
292
+ layout: @pipelines.post_layout,
293
+ entries: [
294
+ { binding: 0, texture_view: @resources.hdr_view },
295
+ { binding: 1, sampler: @post_sampler }
296
+ ]
297
+ )
298
+ @post_pipeline = @pipelines.post_pipeline(surface_format)
299
+ end
300
+
301
+ def encode_post_pass(encoder, target_view)
302
+ pass = encoder.begin_render_pass(
303
+ label: "stagecraft post",
304
+ color_attachments: [{ view: target_view, load_op: :clear, store_op: :store }]
305
+ )
306
+ pass.set_pipeline(@post_pipeline)
307
+ pass.set_bind_group(0, @post_group)
308
+ pass.draw(3)
309
+ pass.end_pass
310
+ end
311
+
312
+ def release_post_resources
313
+ @post_group&.release
314
+ @post_sampler&.release
315
+ @post_group = @post_sampler = nil
316
+ end
317
+
318
+ def elapsed_time
319
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
320
+ end
321
+
322
+ def report_debug_stats
323
+ return unless ENV["STAGECRAFT_DEBUG"] == "1"
324
+
325
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
326
+ return if @last_debug_report && now - @last_debug_report < 1.0
327
+
328
+ warn "Stagecraft #{stats}"
329
+ @last_debug_report = now
330
+ end
331
+
332
+ def ensure_alive!
333
+ raise DisposedError, "renderer has been disposed" if disposed?
334
+ end
335
+ end
336
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ class Renderer
5
+ class ResourceCache
6
+ GPUGeometry = Data.define(:vertex_buffers, :index_buffer, :index_format, :index_count, :vertex_count, :layout_id)
7
+ GPUTexture = Data.define(:texture, :view, :sampler)
8
+ Entry = Struct.new(:owner, :version, :resource)
9
+
10
+ attr_reader :frame
11
+
12
+ def initialize(device:, queue:, stats:, in_flight_frames: 3)
13
+ @device = device
14
+ @queue = queue
15
+ @stats = stats
16
+ @in_flight_frames = in_flight_frames
17
+ @geometries = {}
18
+ @textures = {}
19
+ @dispose_queue = []
20
+ @registered = {}
21
+ @frame = 0
22
+ end
23
+
24
+ def gpu_geometry(geometry)
25
+ raise DisposedError, "cannot upload disposed geometry" if geometry.disposed?
26
+
27
+ existing = @geometries[geometry.object_id]
28
+ if existing&.owner&.equal?(geometry) && existing.version == geometry.version
29
+ return existing.resource
30
+ end
31
+
32
+ schedule_resource(existing.resource) if existing
33
+ resource = upload_geometry(geometry)
34
+ @geometries[geometry.object_id] = Entry.new(geometry, geometry.version, resource)
35
+ register_disposal(geometry)
36
+ resource
37
+ end
38
+
39
+ def gpu_texture(texture)
40
+ raise DisposedError, "cannot upload disposed texture" if texture.disposed?
41
+
42
+ existing = @textures[texture.object_id]
43
+ if existing&.owner&.equal?(texture) && existing.version == texture.version
44
+ return existing.resource
45
+ end
46
+
47
+ schedule_resource(existing.resource) if existing
48
+ resource = upload_texture(texture)
49
+ @textures[texture.object_id] = Entry.new(texture, texture.version, resource)
50
+ register_disposal(texture)
51
+ resource
52
+ end
53
+
54
+ def schedule_dispose(cpu_object)
55
+ entry = delete_owned_entry(@geometries, cpu_object) ||
56
+ delete_owned_entry(@textures, cpu_object)
57
+ schedule_resource(entry.resource) if entry
58
+ @registered.delete(cpu_object.object_id)
59
+ self
60
+ end
61
+
62
+ def advance_frame
63
+ @frame += 1
64
+ ready, @dispose_queue = @dispose_queue.partition { |due, _resource| due <= frame }
65
+ ready.each { |_due, resource| destroy_resource(resource) }
66
+ self
67
+ end
68
+
69
+ def dispose_all
70
+ resources = @geometries.values.concat(@textures.values).map(&:resource)
71
+ resources.concat(@dispose_queue.map(&:last))
72
+ resources.each { |resource| destroy_resource(resource) }
73
+ @geometries.clear
74
+ @textures.clear
75
+ @dispose_queue.clear
76
+ @registered.clear
77
+ self
78
+ end
79
+
80
+ def live_count
81
+ @geometries.length + @textures.length
82
+ end
83
+
84
+ private
85
+
86
+ def upload_geometry(geometry)
87
+ buffers = geometry.attributes.to_h do |name, attribute|
88
+ buffer = @device.create_buffer_with_data(
89
+ label: "stagecraft #{name}",
90
+ data: attribute.data,
91
+ usage: %i[vertex copy_dst]
92
+ )
93
+ @stats.increment(:buffers)
94
+ [name, buffer]
95
+ end
96
+ index_buffer = if geometry.index
97
+ @stats.increment(:buffers)
98
+ @device.create_buffer_with_data(
99
+ label: "stagecraft index",
100
+ data: geometry.index.data,
101
+ usage: %i[index copy_dst]
102
+ )
103
+ end
104
+ GPUGeometry.new(
105
+ vertex_buffers: buffers.freeze,
106
+ index_buffer:,
107
+ index_format: geometry.index&.format,
108
+ index_count: geometry.index&.count,
109
+ vertex_count: geometry.attribute(:position).count,
110
+ layout_id: Features.vertex_layout_id(geometry)
111
+ )
112
+ end
113
+
114
+ def upload_texture(texture)
115
+ require "texel/wgpu"
116
+ raise Error, "texture has no image" unless texture.image
117
+
118
+ image = texture.image
119
+ if texture.color_space && image.color_space != texture.color_space
120
+ image = image.dup_with(color_space: texture.color_space)
121
+ end
122
+ gpu_texture = Texel::WGPU.upload(image, device: @device, queue: @queue)
123
+ sampler_state = texture.sampler
124
+ sampler = @device.create_sampler(
125
+ address_mode_u: sampler_state.wrap_u,
126
+ address_mode_v: sampler_state.wrap_v,
127
+ address_mode_w: sampler_state.wrap_w,
128
+ mag_filter: sampler_state.mag_filter,
129
+ min_filter: sampler_state.min_filter,
130
+ mipmap_filter: sampler_state.mipmap_filter,
131
+ lod_min_clamp: sampler_state.lod_min,
132
+ lod_max_clamp: sampler_state.lod_max,
133
+ compare: sampler_state.compare,
134
+ max_anisotropy: sampler_state.max_anisotropy
135
+ )
136
+ @stats.increment(:textures)
137
+ GPUTexture.new(texture: gpu_texture, view: gpu_texture.create_view, sampler:)
138
+ end
139
+
140
+ def register_disposal(object)
141
+ return if @registered[object.object_id]
142
+
143
+ @registered[object.object_id] = true
144
+ object.on_dispose { |disposed| schedule_dispose(disposed) }
145
+ end
146
+
147
+ def delete_owned_entry(collection, owner)
148
+ entry = collection[owner.object_id]
149
+ return unless entry&.owner&.equal?(owner)
150
+
151
+ collection.delete(owner.object_id)
152
+ end
153
+
154
+ def schedule_resource(resource)
155
+ @dispose_queue << [frame + @in_flight_frames, resource]
156
+ end
157
+
158
+ def destroy_resource(resource)
159
+ case resource
160
+ when GPUGeometry
161
+ [*resource.vertex_buffers.values, resource.index_buffer].compact.each do |buffer|
162
+ buffer.destroy if buffer.respond_to?(:destroy)
163
+ buffer.release if buffer.respond_to?(:release)
164
+ @stats.decrement(:buffers)
165
+ end
166
+ when GPUTexture
167
+ resource.sampler.release if resource.sampler.respond_to?(:release)
168
+ resource.view.release if resource.view.respond_to?(:release)
169
+ resource.texture.destroy if resource.texture.respond_to?(:destroy)
170
+ resource.texture.release if resource.texture.respond_to?(:release)
171
+ @stats.decrement(:textures)
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,48 @@
1
+ struct FrameUniforms {
2
+ view_proj: mat4x4f,
3
+ camera_pos: vec3f,
4
+ time: f32,
5
+ ambient: vec3f,
6
+ light_count: u32,
7
+ light_vp: mat4x4f,
8
+ };
9
+
10
+ struct Light {
11
+ kind: u32,
12
+ color: vec3f,
13
+ position: vec3f,
14
+ direction: vec3f,
15
+ range: f32,
16
+ cone: vec2f,
17
+ };
18
+
19
+ struct ObjectUniforms {
20
+ model: mat4x4f,
21
+ normal_matrix: mat4x4f,
22
+ receive_shadow: u32,
23
+ };
24
+
25
+ @group(0) @binding(0) var<uniform> frame: FrameUniforms;
26
+ @group(0) @binding(1) var<storage, read> lights: array<Light>;
27
+ @group(0) @binding(2) var shadow_map: texture_depth_2d;
28
+ @group(0) @binding(3) var shadow_sampler: sampler_comparison;
29
+ @group(2) @binding(0) var<uniform> object: ObjectUniforms;
30
+ @group(2) @binding(1) var<storage, read> joints: array<mat4x4f>;
31
+
32
+ fn shadow_visibility(world_position: vec3f) -> f32 {
33
+ let projected = frame.light_vp * vec4f(world_position, 1.0);
34
+ let uvz = projected.xyz / projected.w;
35
+ let uv = vec2f(uvz.x * 0.5 + 0.5, 0.5 - uvz.y * 0.5);
36
+ if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0 || uvz.z > 1.0) {
37
+ return 1.0;
38
+ }
39
+ var visibility = 0.0;
40
+ let dimensions = vec2f(textureDimensions(shadow_map));
41
+ for (var y = -1; y <= 1; y = y + 1) {
42
+ for (var x = -1; x <= 1; x = x + 1) {
43
+ let offset = vec2f(f32(x), f32(y)) / dimensions;
44
+ visibility += textureSampleCompare(shadow_map, shadow_sampler, uv + offset, uvz.z - 0.001);
45
+ }
46
+ }
47
+ return visibility / 9.0;
48
+ }