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,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support/demo"
4
+
5
+ demo = StagecraftExamples::Demo.new(title: "03 — PBR material grid")
6
+ demo.light!
7
+ 5.times do |row|
8
+ 5.times do |column|
9
+ material = Stagecraft::Materials::PBR.new(
10
+ base_color: "#d6d9df",
11
+ metallic: column / 4.0,
12
+ roughness: [row / 4.0, 0.04].max
13
+ )
14
+ sphere = Stagecraft::Mesh.new(Stagecraft::Geometries.sphere(0.38), material)
15
+ sphere.position.set((column - 2) * 0.9, (row - 2) * 0.9, 0)
16
+ demo.scene.add(sphere)
17
+ end
18
+ end
19
+ demo.camera.position.set(0, 0, 7)
20
+ demo.run
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support/demo"
4
+
5
+ demo = StagecraftExamples::Demo.new(title: "04 — Light types")
6
+ demo.scene.add(Stagecraft::Lights::Ambient.new(intensity: 0.08))
7
+ point = Stagecraft::Lights::Point.new(color: "#ff5252", intensity: 25, range: 8)
8
+ point.position.set(-2, 2, 2)
9
+ spot = Stagecraft::Lights::Spot.new(
10
+ color: "#40c4ff", intensity: 35, range: 10,
11
+ inner_angle: 0.2, outer_angle: 0.55
12
+ )
13
+ spot.position.set(2, 3, 3)
14
+ spot.look_at(Larb::Vec3.new)
15
+ demo.scene.add(point, spot)
16
+ demo.scene.add(
17
+ Stagecraft::Mesh.new(
18
+ Stagecraft::Geometries.sphere(1.2),
19
+ Stagecraft::Materials::PBR.new(base_color: "#eeeeee", roughness: 0.45)
20
+ )
21
+ )
22
+ demo.run
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support/demo"
4
+
5
+ demo = StagecraftExamples::Demo.new(title: "05 — Directional shadow")
6
+ demo.scene.add(Stagecraft::Lights::Ambient.new(intensity: 0.12))
7
+ sun = Stagecraft::Lights::Directional.new(intensity: 4.0, cast_shadow: true)
8
+ sun.position.set(4, 8, 5)
9
+ sun.look_at(Larb::Vec3.new)
10
+ cube = Stagecraft::Mesh.new(
11
+ Stagecraft::Geometries.box,
12
+ Stagecraft::Materials::PBR.new(base_color: "#ffab40", roughness: 0.5)
13
+ )
14
+ cube.position.y = 0.7
15
+ cube.cast_shadow = true
16
+ floor = Stagecraft::Mesh.new(
17
+ Stagecraft::Geometries.plane(12, 12),
18
+ Stagecraft::Materials::PBR.new(base_color: "#78909c", roughness: 0.9)
19
+ )
20
+ floor.rotation.rotate_x!(-Math::PI / 2)
21
+ floor.receive_shadow = true
22
+ demo.scene.add(sun, cube, floor)
23
+ demo.run
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support/demo"
4
+
5
+ demo = StagecraftExamples::Demo.new(title: "06 — Transparent sorting")
6
+ colors = %w[#ff174480 #00e5ff80 #76ff0380]
7
+ colors.each_with_index do |color, index|
8
+ material = Stagecraft::Materials::Unlit.new(
9
+ color:,
10
+ transparent: true,
11
+ alpha_mode: :blend,
12
+ depth_write: false
13
+ )
14
+ plane = Stagecraft::Mesh.new(Stagecraft::Geometries.plane(2.4, 2.4), material)
15
+ plane.position.set((index - 1) * 0.6, 0, -index * 0.5)
16
+ demo.scene.add(plane)
17
+ end
18
+ demo.camera.position.set(0, 0, 5)
19
+ demo.run
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support/demo"
4
+ require "texel"
5
+
6
+ pixels = [
7
+ 255, 255, 255, 255, 20, 20, 20, 255,
8
+ 20, 20, 20, 255, 255, 255, 255, 255
9
+ ].pack("C*")
10
+ image = Texel::Image.new(
11
+ width: 2, height: 2, channels: 4, dtype: :u8, color_space: :srgb, data: pixels
12
+ )
13
+ texture = Stagecraft::Texture.new(image, color_space: :srgb)
14
+ demo = StagecraftExamples::Demo.new(title: "07 — Texture")
15
+ demo.light!
16
+ demo.scene.add(
17
+ Stagecraft::Mesh.new(
18
+ Stagecraft::Geometries.box(2, 2, 2),
19
+ Stagecraft::Materials::PBR.new(base_color_map: texture, roughness: 0.7)
20
+ )
21
+ )
22
+ demo.run
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support/demo"
4
+
5
+ wgsl = <<~WGSL
6
+ //#include "common.wgsl"
7
+ struct VertexInput { @location(0) position: vec3f };
8
+ struct VertexOutput {
9
+ @builtin(position) position: vec4f,
10
+ @location(0) local: vec3f,
11
+ };
12
+ @vertex fn vs_main(input: VertexInput) -> VertexOutput {
13
+ var output: VertexOutput;
14
+ output.position = frame.view_proj * object.model * vec4f(input.position, 1.0);
15
+ output.local = input.position;
16
+ return output;
17
+ }
18
+ @fragment fn fs_main(input: VertexOutput) -> @location(0) vec4f {
19
+ let pulse = 0.55 + 0.45 * sin(frame.time * material.speed);
20
+ return vec4f(material.tint.rgb * pulse, material.tint.a);
21
+ }
22
+ WGSL
23
+
24
+ demo = StagecraftExamples::Demo.new(title: "08 — Shader material")
25
+ material = Stagecraft::Materials::Shader.new(
26
+ wgsl:,
27
+ uniforms: { tint: Stagecraft::Color.new("#7c4dff"), speed: 3.0 }
28
+ )
29
+ demo.scene.add(Stagecraft::Mesh.new(Stagecraft::Geometries.torus, material))
30
+ demo.run
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support/demo"
4
+ require "rgltf"
5
+
6
+ positions = [-1, -1, 0, 1, -1, 0, 0, 1, 0].pack("e*")
7
+ json = {
8
+ "asset" => { "version" => "2.0" },
9
+ "buffers" => [{ "byteLength" => positions.bytesize }],
10
+ "bufferViews" => [{ "buffer" => 0, "byteLength" => positions.bytesize }],
11
+ "accessors" => [
12
+ { "bufferView" => 0, "componentType" => 5_126, "count" => 3, "type" => "VEC3" }
13
+ ],
14
+ "meshes" => [{ "primitives" => [{ "attributes" => { "POSITION" => 0 } }] }],
15
+ "nodes" => [{ "name" => "triangle", "mesh" => 0 }],
16
+ "scenes" => [{ "nodes" => [0] }],
17
+ "scene" => 0
18
+ }
19
+ document = Rgltf.load_json(json, buffers: positions)
20
+ model = Stagecraft::Loaders::GLTF.load(document)
21
+ demo = StagecraftExamples::Demo.new(title: "09 — glTF loader")
22
+ demo.scene.add(model.scene)
23
+ demo.camera.position.set(0, 0, 4)
24
+ demo.run
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support/demo"
4
+
5
+ demo = StagecraftExamples::Demo.new(title: "10 — Animation mixer")
6
+ demo.light!
7
+ cube = Stagecraft::Mesh.new(
8
+ Stagecraft::Geometries.box,
9
+ Stagecraft::Materials::PBR.new(base_color: "#00b0ff")
10
+ )
11
+ demo.scene.add(cube)
12
+ track = Stagecraft::Animation::Track.new(
13
+ times: [0.0, 1.0, 2.0],
14
+ values: [-2, 0, 0, 2, 0, 0, -2, 0, 0],
15
+ target: cube,
16
+ target_path: :translation
17
+ )
18
+ mixer = Stagecraft::Animation::Mixer.new(demo.scene)
19
+ mixer.play(Stagecraft::Animation::Clip.new(name: "slide", tracks: [track]))
20
+ demo.update { |dt| mixer.update(dt) }
21
+ demo.run
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support/demo"
4
+
5
+ demo = StagecraftExamples::Demo.new(title: "11 — Scene graph")
6
+ demo.light!
7
+ pivot = Stagecraft::Node.new(name: "pivot")
8
+ sun = Stagecraft::Mesh.new(
9
+ Stagecraft::Geometries.sphere(0.7),
10
+ Stagecraft::Materials::PBR.new(base_color: "#ffd740", emissive: "#ffab00", emissive_strength: 0.5)
11
+ )
12
+ moon = Stagecraft::Mesh.new(
13
+ Stagecraft::Geometries.sphere(0.25),
14
+ Stagecraft::Materials::PBR.new(base_color: "#b0bec5")
15
+ )
16
+ moon.position.x = 2.0
17
+ pivot.add(sun, moon)
18
+ demo.scene.add(pivot)
19
+ demo.update { |dt| pivot.rotation.rotate_y!(dt) }
20
+ demo.run
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support/demo"
4
+
5
+ demo = StagecraftExamples::Demo.new(title: "12 — Orbit controls")
6
+ demo.light!
7
+ demo.scene.add(
8
+ Stagecraft::Mesh.new(
9
+ Stagecraft::Geometries.torus(1.2, 0.35),
10
+ Stagecraft::Materials::PBR.new(base_color: "#18ffff", metallic: 0.65, roughness: 0.22)
11
+ )
12
+ )
13
+ demo.run
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support/demo"
4
+
5
+ demo = StagecraftExamples::Demo.new(title: "13 — Orthographic camera", controls: false)
6
+ demo.camera = Stagecraft::Cameras::Orthographic.new(
7
+ left: -4, right: 4, top: 2.25, bottom: -2.25, near: 0.1, far: 20
8
+ )
9
+ demo.camera.position.z = 6
10
+ 7.times do |index|
11
+ square = Stagecraft::Mesh.new(
12
+ Stagecraft::Geometries.plane(0.8, 0.8),
13
+ Stagecraft::Materials::Unlit.new(color: Stagecraft::Color.new(index / 7.0, 0.5, 1.0))
14
+ )
15
+ square.position.x = index - 3
16
+ demo.scene.add(square)
17
+ end
18
+ demo.run
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support/demo"
4
+
5
+ demo = StagecraftExamples::Demo.new(title: "14 — Raycaster")
6
+ mesh = Stagecraft::Mesh.new(
7
+ Stagecraft::Geometries.sphere,
8
+ Stagecraft::Materials::Unlit.new(color: "#69f0ae")
9
+ )
10
+ demo.scene.add(mesh)
11
+ raycaster = Stagecraft::Raycaster.new
12
+ raycaster.set_from_camera(0.0, 0.0, demo.camera)
13
+ warn "center ray hits: #{raycaster.intersect(demo.scene).map { |hit| hit.object.name || "mesh" }.join(", ")}"
14
+ demo.run
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support/demo"
4
+ require "texel"
5
+
6
+ scene = Stagecraft::Scene.new
7
+ scene.background = "#263238"
8
+ scene.add(
9
+ Stagecraft::Mesh.new(
10
+ Stagecraft::Geometries.box,
11
+ Stagecraft::Materials::Unlit.new(color: "#ff4081")
12
+ )
13
+ )
14
+ camera = Stagecraft::Cameras::Perspective.new(aspect: 1.0)
15
+ camera.position.z = 3
16
+ renderer = Stagecraft::Renderer.offscreen(width: 512, height: 512, msaa: 4)
17
+ image = renderer.render(scene, camera).read_pixels
18
+ destination = ARGV.first || File.expand_path("../tmp/stagecraft-offscreen.png", __dir__)
19
+ Dir.mkdir(File.dirname(destination)) unless Dir.exist?(File.dirname(destination))
20
+ Texel.save(image, destination)
21
+ renderer.dispose
22
+ puts destination
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support/demo"
4
+
5
+ demo = StagecraftExamples::Demo.new(title: "16 — 4x MSAA", msaa: 4)
6
+ demo.light!
7
+ demo.scene.add(
8
+ Stagecraft::Mesh.new(
9
+ Stagecraft::Geometries.torus(1.2, 0.18, radial_segments: 8, tubular_segments: 96),
10
+ Stagecraft::Materials::PBR.new(base_color: "#ffffff", roughness: 0.25)
11
+ )
12
+ )
13
+ demo.run
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support/demo"
4
+
5
+ Body = Struct.new(:position, :rotation)
6
+ demo = StagecraftExamples::Demo.new(title: "17 — Physics binding")
7
+ demo.light!
8
+ mesh = Stagecraft::Mesh.new(
9
+ Stagecraft::Geometries.box,
10
+ Stagecraft::Materials::PBR.new(base_color: "#ff6d00")
11
+ )
12
+ body = Body.new(Larb::Vec3.new, Larb::Quat.new)
13
+ binding = Stagecraft::PhysicsBinding.new.bind(mesh, body)
14
+ elapsed = 0.0
15
+ demo.scene.add(mesh)
16
+ demo.update do |dt|
17
+ elapsed += dt
18
+ body.position = Larb::Vec3.new(Math.sin(elapsed) * 2.0, 0, 0)
19
+ body.rotation = Larb::Quat.from_axis_angle(Larb::Vec3.up, elapsed)
20
+ binding.sync!(1.0)
21
+ end
22
+ demo.run
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support/demo"
4
+
5
+ geometry = Stagecraft::Geometry.new
6
+ geometry.set_attribute(
7
+ :position,
8
+ data: [0, 1.5, 0, -1.5, -1, 0, 1.5, -1, 0].pack("e*"),
9
+ format: :float32x3,
10
+ count: 3
11
+ )
12
+ geometry.compute_normals!
13
+ demo = StagecraftExamples::Demo.new(title: "18 — Custom geometry")
14
+ demo.light!
15
+ demo.scene.add(
16
+ Stagecraft::Mesh.new(
17
+ geometry,
18
+ Stagecraft::Materials::PBR.new(base_color: "#d500f9", roughness: 0.55)
19
+ )
20
+ )
21
+ demo.camera.position.set(0, 0, 5)
22
+ demo.run
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support/demo"
4
+
5
+ demo = StagecraftExamples::Demo.new(title: "19 — Stats / ImGui hook")
6
+ demo.light!
7
+ demo.scene.add(
8
+ Stagecraft::Mesh.new(
9
+ Stagecraft::Geometries.sphere,
10
+ Stagecraft::Materials::PBR.new(base_color: "#64ffda")
11
+ )
12
+ )
13
+
14
+ # An imgui-ruby WGPU backend can encode its draw data in this callback. The same
15
+ # device and queue are available as demo.app.renderer.device / .queue.
16
+ last_report = 0.0
17
+ demo.app.renderer.on_after_render do |_encoder, _target_view|
18
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
19
+ next unless now - last_report >= 1.0
20
+
21
+ warn demo.app.renderer.stats.to_s
22
+ last_report = now
23
+ end
24
+ demo.run
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support/demo"
4
+
5
+ # SDL3 is the portable default. Set STAGECRAFT_WINDOW=glfw only when supplying
6
+ # the platform-specific WebGPU surface bridge described in the README.
7
+ window = ENV.fetch("STAGECRAFT_WINDOW", "sdl3").to_sym
8
+ demo = StagecraftExamples::Demo.new(title: "20 — #{window} adapter", window:)
9
+ demo.scene.add(
10
+ Stagecraft::Mesh.new(
11
+ Stagecraft::Geometries.box,
12
+ Stagecraft::Materials::Unlit.new(color: "#448aff")
13
+ )
14
+ )
15
+ demo.run
@@ -0,0 +1,11 @@
1
+ # Stagecraft gallery
2
+
3
+ Run any example from the repository root:
4
+
5
+ ```sh
6
+ bundle exec ruby examples/01_spinning_cube.rb
7
+ ```
8
+
9
+ The window examples use SDL3 by default. Set `STAGECRAFT_SMOKE=1` to stop after
10
+ one rendered frame. Example 15 is headless and writes a PNG; it accepts the
11
+ destination path as its first argument.
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../../lib", __dir__)
4
+
5
+ require "stagecraft"
6
+
7
+ module StagecraftExamples
8
+ class Demo
9
+ attr_reader :app, :scene
10
+ attr_accessor :camera
11
+
12
+ def initialize(title:, width: 960, height: 540, msaa: 4, window: :sdl3, controls: true)
13
+ @app = Stagecraft::App.new(title:, width:, height:, msaa:, window:)
14
+ @scene = Stagecraft::Scene.new
15
+ @scene.background = "#10141d"
16
+ @camera = Stagecraft::Cameras::Perspective.new(
17
+ fov: 55, aspect: app.aspect, near: 0.1, far: 200
18
+ )
19
+ @camera.position.set(0, 2, 6)
20
+ @controls_enabled = controls
21
+ @updates = []
22
+ app.window.on_resize do |next_width, next_height|
23
+ camera.aspect = next_width.to_f / [next_height, 1].max if camera.respond_to?(:aspect=)
24
+ end
25
+ end
26
+
27
+ def light!
28
+ scene.add(Stagecraft::Lights::Ambient.new(intensity: 0.3))
29
+ sun = Stagecraft::Lights::Directional.new(intensity: 3.0)
30
+ sun.position.set(4, 7, 5)
31
+ scene.add(sun)
32
+ sun
33
+ end
34
+
35
+ def update(&block)
36
+ @updates << block
37
+ self
38
+ end
39
+
40
+ def run
41
+ controls = Stagecraft::Controls::Orbit.new(camera, app.window) if @controls_enabled
42
+ app.run do |dt|
43
+ controls&.update(dt)
44
+ @updates.each { |callback| callback.call(dt) }
45
+ app.renderer.render(scene, camera)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ module Animation
5
+ class Action
6
+ LOOPS = %i[repeat once ping_pong].freeze
7
+
8
+ attr_reader :clip, :time, :loop
9
+ attr_accessor :weight, :time_scale, :enabled, :clamp_when_finished
10
+
11
+ def initialize(clip, loop: :repeat, fade_in: 0.0)
12
+ @clip = clip
13
+ @loop = loop.to_sym
14
+ raise ArgumentError, "invalid animation loop #{loop.inspect}" unless LOOPS.include?(@loop)
15
+
16
+ @time = 0.0
17
+ @weight = fade_in.to_f.positive? ? 0.0 : 1.0
18
+ @fade_duration = [Float(fade_in), 0.0].max
19
+ @fade_elapsed = 0.0
20
+ @time_scale = 1.0
21
+ @enabled = true
22
+ @clamp_when_finished = true
23
+ @ping_pong_phase = 0.0
24
+ end
25
+
26
+ def advance(dt)
27
+ return time unless enabled
28
+
29
+ elapsed = Float(dt)
30
+ update_fade(elapsed)
31
+ duration = clip.duration
32
+ return @time = 0.0 if duration.zero?
33
+
34
+ delta = elapsed * time_scale
35
+ return advance_ping_pong(delta, duration) if loop == :ping_pong
36
+
37
+ @time += delta
38
+ apply_loop(duration)
39
+ time
40
+ end
41
+
42
+ def stop
43
+ @enabled = false
44
+ self
45
+ end
46
+
47
+ private
48
+
49
+ def update_fade(dt)
50
+ return unless @fade_duration.positive? && weight < 1.0
51
+
52
+ @fade_elapsed += [dt, 0.0].max
53
+ @weight = [@fade_elapsed / @fade_duration, 1.0].min
54
+ end
55
+
56
+ def apply_loop(duration)
57
+ case loop
58
+ when :repeat
59
+ @time %= duration
60
+ when :once
61
+ return unless @time > duration || @time.negative?
62
+
63
+ @time = @time.clamp(0.0, duration)
64
+ @enabled = false unless clamp_when_finished
65
+ end
66
+ end
67
+
68
+ def advance_ping_pong(delta, duration)
69
+ period = duration * 2.0
70
+ @ping_pong_phase = (@ping_pong_phase + delta) % period
71
+ @time = if @ping_pong_phase <= duration
72
+ @ping_pong_phase
73
+ else
74
+ period - @ping_pong_phase
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ module Animation
5
+ class Clip
6
+ attr_reader :name, :tracks, :duration
7
+
8
+ def initialize(name: nil, tracks:, duration: nil)
9
+ @name = name
10
+ @tracks = tracks.freeze
11
+ @duration = duration ? Float(duration) : (@tracks.map(&:duration).max || 0.0)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ module Animation
5
+ class Mixer
6
+ attr_reader :root, :actions
7
+
8
+ def initialize(root)
9
+ @root = root
10
+ @actions = []
11
+ @resolved_targets = {}
12
+ end
13
+
14
+ def play(clip, loop: :repeat, fade_in: 0.0)
15
+ action = Action.new(clip, loop:, fade_in:)
16
+ actions << action
17
+ clip.tracks.each { |track| resolve_target(track) }
18
+ action
19
+ end
20
+
21
+ def stop_all
22
+ actions.each(&:stop)
23
+ actions.clear
24
+ self
25
+ end
26
+
27
+ def update(dt)
28
+ samples = Hash.new { |hash, key| hash[key] = [] }
29
+ actions.each do |action|
30
+ next unless action.enabled
31
+
32
+ action.advance(dt)
33
+ action.clip.tracks.each do |track|
34
+ target = resolve_target(track)
35
+ next unless target
36
+
37
+ samples[[target, track.target_path]] << [track.sample(action.time), action.weight]
38
+ end
39
+ end
40
+ samples.each { |(target, path), values| apply_blend(target, path, values) }
41
+ self
42
+ end
43
+
44
+ private
45
+
46
+ def resolve_target(track)
47
+ @resolved_targets[track.object_id] ||= case track.target
48
+ when Node then track.target
49
+ when String, Symbol then root.find(track.target.to_s)
50
+ else track.target
51
+ end
52
+ end
53
+
54
+ def apply_blend(target, path, samples)
55
+ total = samples.sum(&:last)
56
+ return if total.zero?
57
+
58
+ value = if path == :rotation
59
+ blend_quaternions(samples, total)
60
+ else
61
+ blend_vectors(samples, total)
62
+ end
63
+ case path
64
+ when :translation then target.position.set(value)
65
+ when :rotation then target.rotation.set(value)
66
+ when :scale then target.scale.set(value)
67
+ when :weights
68
+ if target.respond_to?(:morph_weights=)
69
+ target.morph_weights = value
70
+ elsif target.respond_to?(:traverse)
71
+ target.traverse do |node|
72
+ node.morph_weights = value.dup if node.respond_to?(:morph_weights=)
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ def blend_vectors(samples, total)
79
+ size = samples.first.first.length
80
+ Array.new(size) do |index|
81
+ samples.sum { |value, weight| value[index] * weight } / total
82
+ end
83
+ end
84
+
85
+ def blend_quaternions(samples, total)
86
+ reference = samples.first.first
87
+ components = Array.new(4, 0.0)
88
+ samples.each do |value, weight|
89
+ dot = 4.times.sum { |index| reference[index] * value[index] }
90
+ sign = dot.negative? ? -1.0 : 1.0
91
+ 4.times { |index| components[index] += value[index] * weight * sign }
92
+ end
93
+ Larb::Quat.new(*components.map { |component| component / total }).normalize.to_a
94
+ end
95
+ end
96
+ end
97
+ end