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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +207 -0
- data/Rakefile +8 -0
- data/docs/api.md +85 -0
- data/docs/tutorial.md +344 -0
- data/examples/01_spinning_cube.rb +13 -0
- data/examples/02_primitives.rb +21 -0
- data/examples/03_pbr_materials.rb +20 -0
- data/examples/04_lights.rb +22 -0
- data/examples/05_directional_shadow.rb +23 -0
- data/examples/06_transparency.rb +19 -0
- data/examples/07_texture.rb +22 -0
- data/examples/08_shader_material.rb +30 -0
- data/examples/09_gltf_loader.rb +24 -0
- data/examples/10_animation.rb +21 -0
- data/examples/11_scene_graph.rb +20 -0
- data/examples/12_orbit_controls.rb +13 -0
- data/examples/13_orthographic_camera.rb +18 -0
- data/examples/14_raycaster.rb +14 -0
- data/examples/15_offscreen.rb +22 -0
- data/examples/16_msaa.rb +13 -0
- data/examples/17_physics_binding.rb +22 -0
- data/examples/18_custom_geometry.rb +22 -0
- data/examples/19_imgui_stats.rb +24 -0
- data/examples/20_window_adapters.rb +15 -0
- data/examples/README.md +11 -0
- data/examples/support/demo.rb +49 -0
- data/lib/stagecraft/animation/action.rb +79 -0
- data/lib/stagecraft/animation/clip.rb +15 -0
- data/lib/stagecraft/animation/mixer.rb +97 -0
- data/lib/stagecraft/animation/track.rb +122 -0
- data/lib/stagecraft/app.rb +61 -0
- data/lib/stagecraft/cameras/camera.rb +15 -0
- data/lib/stagecraft/cameras/orthographic_camera.rb +60 -0
- data/lib/stagecraft/cameras/perspective_camera.rb +69 -0
- data/lib/stagecraft/color.rb +56 -0
- data/lib/stagecraft/controls/orbit_controls.rb +119 -0
- data/lib/stagecraft/core/attribute.rb +91 -0
- data/lib/stagecraft/core/bounding.rb +131 -0
- data/lib/stagecraft/core/geometry.rb +155 -0
- data/lib/stagecraft/core/mesh.rb +26 -0
- data/lib/stagecraft/core/node.rb +163 -0
- data/lib/stagecraft/core/observed_value.rb +182 -0
- data/lib/stagecraft/core/raycaster.rb +62 -0
- data/lib/stagecraft/core/scene.rb +11 -0
- data/lib/stagecraft/core/skin.rb +23 -0
- data/lib/stagecraft/geometries.rb +200 -0
- data/lib/stagecraft/lights/ambient_light.rb +10 -0
- data/lib/stagecraft/lights/directional_light.rb +31 -0
- data/lib/stagecraft/lights/light.rb +30 -0
- data/lib/stagecraft/lights/point_light.rb +23 -0
- data/lib/stagecraft/lights/spot_light.rb +36 -0
- data/lib/stagecraft/loaders/gltf_loader.rb +387 -0
- data/lib/stagecraft/materials/material.rb +58 -0
- data/lib/stagecraft/materials/pbr_material.rb +75 -0
- data/lib/stagecraft/materials/shader_material.rb +21 -0
- data/lib/stagecraft/materials/unlit_material.rb +26 -0
- data/lib/stagecraft/physics_binding.rb +63 -0
- data/lib/stagecraft/renderer/features.rb +58 -0
- data/lib/stagecraft/renderer/frame.rb +45 -0
- data/lib/stagecraft/renderer/frame_resources.rb +467 -0
- data/lib/stagecraft/renderer/gpu_context.rb +120 -0
- data/lib/stagecraft/renderer/pipeline_cache.rb +59 -0
- data/lib/stagecraft/renderer/pipeline_factory.rb +470 -0
- data/lib/stagecraft/renderer/render_list.rb +80 -0
- data/lib/stagecraft/renderer/renderer.rb +336 -0
- data/lib/stagecraft/renderer/resource_cache.rb +176 -0
- data/lib/stagecraft/renderer/shaders/common.wgsl +48 -0
- data/lib/stagecraft/renderer/shaders/pbr.wgsl +251 -0
- data/lib/stagecraft/renderer/shaders/shadow.wgsl +37 -0
- data/lib/stagecraft/renderer/shaders/skinning.wgsl +6 -0
- data/lib/stagecraft/renderer/shaders/tonemap.wgsl +37 -0
- data/lib/stagecraft/renderer/shaders/unlit.wgsl +89 -0
- data/lib/stagecraft/renderer/shaders.rb +64 -0
- data/lib/stagecraft/renderer/stats.rb +41 -0
- data/lib/stagecraft/renderer/uniform_packer.rb +91 -0
- data/lib/stagecraft/renderer/wgpu_compatibility.rb +41 -0
- data/lib/stagecraft/textures/cube_texture.rb +16 -0
- data/lib/stagecraft/textures/sampler_state.rb +58 -0
- data/lib/stagecraft/textures/texture.rb +71 -0
- data/lib/stagecraft/version.rb +5 -0
- data/lib/stagecraft/window/adapter.rb +38 -0
- data/lib/stagecraft/window/glfw.rb +67 -0
- data/lib/stagecraft/window/sdl3.rb +57 -0
- data/lib/stagecraft.rb +60 -0
- metadata +187 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stagecraft
|
|
4
|
+
module Animation
|
|
5
|
+
class Track
|
|
6
|
+
PATH_COMPONENTS = { translation: 3, rotation: 4, scale: 3 }.freeze
|
|
7
|
+
INTERPOLATIONS = %i[linear step cubicspline].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :times, :values, :target, :target_path, :interpolation, :value_size
|
|
10
|
+
|
|
11
|
+
def initialize(times:, values:, target:, target_path:, interpolation: :linear, value_size: nil)
|
|
12
|
+
@times = unpack(times)
|
|
13
|
+
@values = unpack(values)
|
|
14
|
+
@target = target
|
|
15
|
+
@target_path = target_path.to_sym
|
|
16
|
+
@interpolation = interpolation.to_sym
|
|
17
|
+
raise ArgumentError, "unsupported interpolation #{interpolation.inspect}" unless INTERPOLATIONS.include?(@interpolation)
|
|
18
|
+
raise ArgumentError, "animation times cannot be empty" if @times.empty?
|
|
19
|
+
raise ArgumentError, "animation times must be sorted" unless @times.each_cons(2).all? { |left, right| left <= right }
|
|
20
|
+
|
|
21
|
+
@value_size = value_size || infer_value_size
|
|
22
|
+
expected = @times.length * @value_size * (cubicspline? ? 3 : 1)
|
|
23
|
+
raise ArgumentError, "animation value count #{values_count} does not match expected #{expected}" unless values_count == expected
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def duration
|
|
27
|
+
times.last
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def sample(time, out = nil)
|
|
31
|
+
value = sample_value(Float(time))
|
|
32
|
+
assign(out, value) if out
|
|
33
|
+
value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def unpack(value)
|
|
39
|
+
value.is_a?(String) ? value.unpack("e*") : value.to_a.map(&:to_f)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def infer_value_size
|
|
43
|
+
PATH_COMPONENTS[target_path] || begin
|
|
44
|
+
divisor = times.length * (cubicspline? ? 3 : 1)
|
|
45
|
+
values.length / divisor
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def values_count
|
|
50
|
+
values.length
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def cubicspline?
|
|
54
|
+
interpolation == :cubicspline
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def sample_value(time)
|
|
58
|
+
return key_value(0) if time <= times.first
|
|
59
|
+
return key_value(times.length - 1) if time >= times.last
|
|
60
|
+
|
|
61
|
+
upper = times.bsearch_index { |key_time| key_time > time }
|
|
62
|
+
lower = upper - 1
|
|
63
|
+
duration = times[upper] - times[lower]
|
|
64
|
+
amount = duration.zero? ? 0.0 : (time - times[lower]) / duration
|
|
65
|
+
case interpolation
|
|
66
|
+
when :step then key_value(lower)
|
|
67
|
+
when :cubicspline then cubic_value(lower, upper, amount, duration)
|
|
68
|
+
else linear_value(lower, upper, amount)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def key_value(index)
|
|
73
|
+
value_index = cubicspline? ? ((index * 3) + 1) : index
|
|
74
|
+
slice(value_index)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def linear_value(lower, upper, amount)
|
|
78
|
+
first = key_value(lower)
|
|
79
|
+
second = key_value(upper)
|
|
80
|
+
return quaternion(first).slerp(quaternion(second), amount).to_a if target_path == :rotation
|
|
81
|
+
|
|
82
|
+
first.zip(second).map { |left, right| left + ((right - left) * amount) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def cubic_value(lower, upper, amount, duration)
|
|
86
|
+
p0 = slice((lower * 3) + 1)
|
|
87
|
+
m0 = slice((lower * 3) + 2)
|
|
88
|
+
p1 = slice((upper * 3) + 1)
|
|
89
|
+
m1 = slice(upper * 3)
|
|
90
|
+
t2 = amount * amount
|
|
91
|
+
t3 = t2 * amount
|
|
92
|
+
h00 = (2 * t3) - (3 * t2) + 1
|
|
93
|
+
h10 = t3 - (2 * t2) + amount
|
|
94
|
+
h01 = (-2 * t3) + (3 * t2)
|
|
95
|
+
h11 = t3 - t2
|
|
96
|
+
result = value_size.times.map do |index|
|
|
97
|
+
(h00 * p0[index]) + (h10 * duration * m0[index]) +
|
|
98
|
+
(h01 * p1[index]) + (h11 * duration * m1[index])
|
|
99
|
+
end
|
|
100
|
+
target_path == :rotation ? quaternion(result).normalize.to_a : result
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def slice(index)
|
|
104
|
+
values.slice(index * value_size, value_size)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def quaternion(value)
|
|
108
|
+
Larb::Quat.new(*value)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def assign(out, value)
|
|
112
|
+
if out.respond_to?(:set)
|
|
113
|
+
out.set(*value)
|
|
114
|
+
elsif out.respond_to?(:replace)
|
|
115
|
+
out.replace(value)
|
|
116
|
+
else
|
|
117
|
+
raise ArgumentError, "animation output must support #set or #replace"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stagecraft
|
|
4
|
+
class App
|
|
5
|
+
attr_reader :window, :renderer
|
|
6
|
+
|
|
7
|
+
def initialize(title: "Stagecraft", width: 800, height: 600, msaa: 4,
|
|
8
|
+
window: :sdl3, renderer: nil, resizable: true)
|
|
9
|
+
@window = build_window(window, title:, width:, height:, resizable:)
|
|
10
|
+
@renderer = renderer || Renderer.new(window: @window, width:, height:, msaa:)
|
|
11
|
+
rescue StandardError
|
|
12
|
+
@window&.close
|
|
13
|
+
raise
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def aspect
|
|
17
|
+
width, height = window.drawable_size
|
|
18
|
+
height.zero? ? 1.0 : width.to_f / height
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
raise ArgumentError, "App#run requires a block" unless block_given?
|
|
23
|
+
|
|
24
|
+
previous = monotonic_time
|
|
25
|
+
loop do
|
|
26
|
+
window.poll_events
|
|
27
|
+
break if window.should_close?
|
|
28
|
+
|
|
29
|
+
now = monotonic_time
|
|
30
|
+
dt = (now - previous).clamp(0.0, 0.1)
|
|
31
|
+
previous = now
|
|
32
|
+
yield(dt)
|
|
33
|
+
break if ENV["STAGECRAFT_SMOKE"] == "1"
|
|
34
|
+
end
|
|
35
|
+
self
|
|
36
|
+
ensure
|
|
37
|
+
begin
|
|
38
|
+
renderer.dispose
|
|
39
|
+
ensure
|
|
40
|
+
window.close
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def build_window(value, **options)
|
|
47
|
+
return value unless value.is_a?(Symbol) || value.is_a?(String)
|
|
48
|
+
|
|
49
|
+
case value.to_sym
|
|
50
|
+
when :sdl3 then Window::Sdl3.new(**options)
|
|
51
|
+
when :glfw then Window::Glfw.new(**options)
|
|
52
|
+
else
|
|
53
|
+
raise ArgumentError, "unknown window adapter #{value.inspect}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def monotonic_time
|
|
58
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stagecraft
|
|
4
|
+
module Cameras
|
|
5
|
+
class Orthographic < Camera
|
|
6
|
+
PROPERTIES = %i[left right top bottom near far].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader(*PROPERTIES)
|
|
9
|
+
|
|
10
|
+
def initialize(left: -1.0, right: 1.0, top: 1.0, bottom: -1.0, near: 0.1, far: 2_000.0, **)
|
|
11
|
+
super(**)
|
|
12
|
+
@projection_revision = 0
|
|
13
|
+
@cached_projection_revision = -1
|
|
14
|
+
{ left:, right:, top:, bottom:, near:, far: }.each do |name, value|
|
|
15
|
+
public_send(:"#{name}=", value)
|
|
16
|
+
end
|
|
17
|
+
validate_extents!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
PROPERTIES.each do |property|
|
|
21
|
+
define_method(:"#{property}=") do |value|
|
|
22
|
+
number = Float(value)
|
|
23
|
+
variable = :"@#{property}"
|
|
24
|
+
return number if instance_variable_defined?(variable) && instance_variable_get(variable) == number
|
|
25
|
+
|
|
26
|
+
instance_variable_set(variable, number)
|
|
27
|
+
@projection_revision += 1
|
|
28
|
+
number
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def projection_matrix
|
|
33
|
+
return @projection_matrix if @cached_projection_revision == @projection_revision
|
|
34
|
+
|
|
35
|
+
validate_extents!
|
|
36
|
+
width = right - left
|
|
37
|
+
height = top - bottom
|
|
38
|
+
depth = far - near
|
|
39
|
+
@projection_matrix = Larb::Mat4.new([
|
|
40
|
+
2.0 / width, 0.0, 0.0, 0.0,
|
|
41
|
+
0.0, 2.0 / height, 0.0, 0.0,
|
|
42
|
+
0.0, 0.0, -1.0 / depth, 0.0,
|
|
43
|
+
-(right + left) / width, -(top + bottom) / height, -near / depth, 1.0
|
|
44
|
+
])
|
|
45
|
+
@cached_projection_revision = @projection_revision
|
|
46
|
+
@projection_matrix
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def validate_extents!
|
|
52
|
+
raise ArgumentError, "left and right must differ" if left == right
|
|
53
|
+
raise ArgumentError, "top and bottom must differ" if top == bottom
|
|
54
|
+
raise ArgumentError, "far must be greater than near" unless far > near
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
OrthographicCamera = Orthographic
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stagecraft
|
|
4
|
+
module Cameras
|
|
5
|
+
class Perspective < Camera
|
|
6
|
+
attr_reader :fov, :aspect, :near, :far
|
|
7
|
+
|
|
8
|
+
def initialize(fov: 50.0, aspect: 1.0, near: 0.1, far: 2_000.0, **)
|
|
9
|
+
super(**)
|
|
10
|
+
@projection_revision = 0
|
|
11
|
+
@cached_projection_revision = -1
|
|
12
|
+
self.fov = fov
|
|
13
|
+
self.aspect = aspect
|
|
14
|
+
self.near = near
|
|
15
|
+
self.far = far
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def fov=(value)
|
|
19
|
+
assign_projection_value(:@fov, Float(value)) { |number| number.positive? && number < 180.0 }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def aspect=(value)
|
|
23
|
+
assign_projection_value(:@aspect, Float(value), &:positive?)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def near=(value)
|
|
27
|
+
assign_projection_value(:@near, Float(value), &:positive?)
|
|
28
|
+
validate_planes! if defined?(@far)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def far=(value)
|
|
32
|
+
assign_projection_value(:@far, Float(value), &:positive?)
|
|
33
|
+
validate_planes! if defined?(@near)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def projection_matrix
|
|
37
|
+
return @projection_matrix if @cached_projection_revision == @projection_revision
|
|
38
|
+
|
|
39
|
+
radians = fov * Math::PI / 180.0
|
|
40
|
+
f = 1.0 / Math.tan(radians / 2.0)
|
|
41
|
+
@projection_matrix = Larb::Mat4.new([
|
|
42
|
+
f / aspect, 0.0, 0.0, 0.0,
|
|
43
|
+
0.0, f, 0.0, 0.0,
|
|
44
|
+
0.0, 0.0, far / (near - far), -1.0,
|
|
45
|
+
0.0, 0.0, (near * far) / (near - far), 0.0
|
|
46
|
+
])
|
|
47
|
+
@cached_projection_revision = @projection_revision
|
|
48
|
+
@projection_matrix
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def assign_projection_value(variable, value)
|
|
54
|
+
raise ArgumentError, "invalid projection value #{value}" unless yield(value)
|
|
55
|
+
return value if instance_variable_defined?(variable) && instance_variable_get(variable) == value
|
|
56
|
+
|
|
57
|
+
instance_variable_set(variable, value)
|
|
58
|
+
@projection_revision += 1
|
|
59
|
+
value
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_planes!
|
|
63
|
+
raise ArgumentError, "far must be greater than near" unless far > near
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
PerspectiveCamera = Perspective
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stagecraft
|
|
4
|
+
class Color
|
|
5
|
+
HEX_PATTERN = /\A#?([0-9a-f]{6})([0-9a-f]{2})?\z/i
|
|
6
|
+
|
|
7
|
+
attr_reader :r, :g, :b, :a
|
|
8
|
+
|
|
9
|
+
def initialize(value = 0.0, green = nil, blue = nil, alpha = 1.0)
|
|
10
|
+
components = normalize(value, green, blue, alpha)
|
|
11
|
+
@r, @g, @b, @a = components.map(&:to_f)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.coerce(value)
|
|
15
|
+
value.is_a?(self) ? value : new(value)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_a
|
|
19
|
+
[r, g, b, a]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_larb
|
|
23
|
+
Larb::Color.new(r, g, b, a)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def ==(other)
|
|
27
|
+
other.respond_to?(:to_a) && to_a == other.to_a
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def normalize(value, green, blue, alpha)
|
|
33
|
+
return from_hex(value) if value.is_a?(String)
|
|
34
|
+
return normalize_array(value) if value.respond_to?(:to_ary)
|
|
35
|
+
return normalize_array(value.to_a) if green.nil? && value.respond_to?(:to_a)
|
|
36
|
+
return [value, value, value, alpha] if green.nil? && blue.nil?
|
|
37
|
+
|
|
38
|
+
[value, green, blue, alpha]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def from_hex(value)
|
|
42
|
+
match = HEX_PATTERN.match(value)
|
|
43
|
+
raise ArgumentError, "invalid color #{value.inspect}" unless match
|
|
44
|
+
|
|
45
|
+
rgb = match[1].scan(/../).map { |part| part.to_i(16) / 255.0 }
|
|
46
|
+
rgb << (match[2] ? match[2].to_i(16) / 255.0 : 1.0)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def normalize_array(value)
|
|
50
|
+
values = value.to_a
|
|
51
|
+
raise ArgumentError, "color requires 3 or 4 components" unless [3, 4].include?(values.length)
|
|
52
|
+
|
|
53
|
+
values.length == 3 ? [*values, 1.0] : values
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stagecraft
|
|
4
|
+
module Controls
|
|
5
|
+
class Orbit
|
|
6
|
+
attr_reader :camera, :target
|
|
7
|
+
attr_accessor :enabled, :rotate_speed, :zoom_speed, :pan_speed, :damping,
|
|
8
|
+
:min_distance, :max_distance, :min_polar_angle, :max_polar_angle
|
|
9
|
+
|
|
10
|
+
def initialize(camera, window = nil, target: Larb::Vec3.new)
|
|
11
|
+
@camera = camera
|
|
12
|
+
@target = vector(target)
|
|
13
|
+
@enabled = true
|
|
14
|
+
@rotate_speed = 0.005
|
|
15
|
+
@zoom_speed = 0.15
|
|
16
|
+
@pan_speed = 0.002
|
|
17
|
+
@damping = 12.0
|
|
18
|
+
@min_distance = 0.01
|
|
19
|
+
@max_distance = Float::INFINITY
|
|
20
|
+
@min_polar_angle = 1e-4
|
|
21
|
+
@max_polar_angle = Math::PI - 1e-4
|
|
22
|
+
@azimuth_delta = 0.0
|
|
23
|
+
@polar_delta = 0.0
|
|
24
|
+
@zoom_delta = 0.0
|
|
25
|
+
@pan_delta = Larb::Vec3.new
|
|
26
|
+
@dragging = false
|
|
27
|
+
sync_spherical
|
|
28
|
+
attach(window) if window
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def target=(value)
|
|
32
|
+
@target = vector(value)
|
|
33
|
+
sync_spherical
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def rotate(delta_x, delta_y)
|
|
37
|
+
return self unless enabled
|
|
38
|
+
|
|
39
|
+
@azimuth_delta -= Float(delta_x) * rotate_speed
|
|
40
|
+
@polar_delta -= Float(delta_y) * rotate_speed
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def zoom(delta)
|
|
45
|
+
return self unless enabled
|
|
46
|
+
|
|
47
|
+
@zoom_delta += Float(delta) * zoom_speed
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def pan(delta_x, delta_y)
|
|
52
|
+
return self unless enabled
|
|
53
|
+
|
|
54
|
+
rotation = camera.world_matrix.extract_rotation
|
|
55
|
+
right = rotation * Larb::Vec3.right
|
|
56
|
+
up = rotation * Larb::Vec3.up
|
|
57
|
+
scale = @radius * pan_speed
|
|
58
|
+
@pan_delta = @pan_delta + (right * (-Float(delta_x) * scale)) + (up * (Float(delta_y) * scale))
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def update(dt = 1.0 / 60.0)
|
|
63
|
+
return self unless enabled
|
|
64
|
+
|
|
65
|
+
response = 1.0 - Math.exp(-damping * [Float(dt), 0.0].max)
|
|
66
|
+
@azimuth += @azimuth_delta * response
|
|
67
|
+
@polar = (@polar + (@polar_delta * response)).clamp(min_polar_angle, max_polar_angle)
|
|
68
|
+
@radius = (@radius * Math.exp(@zoom_delta * response)).clamp(min_distance, max_distance)
|
|
69
|
+
@target = @target + (@pan_delta * response)
|
|
70
|
+
decay = 1.0 - response
|
|
71
|
+
@azimuth_delta *= decay
|
|
72
|
+
@polar_delta *= decay
|
|
73
|
+
@zoom_delta *= decay
|
|
74
|
+
@pan_delta = @pan_delta * decay
|
|
75
|
+
update_camera
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def attach(window)
|
|
82
|
+
window.on_pointer_button do |button, pressed, _x, _y|
|
|
83
|
+
@dragging = pressed if left_button?(button)
|
|
84
|
+
end
|
|
85
|
+
window.on_pointer_move do |_x, _y, relative_x, relative_y|
|
|
86
|
+
rotate(relative_x, relative_y) if @dragging
|
|
87
|
+
end
|
|
88
|
+
window.on_scroll { |_x, y| zoom(-y) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def left_button?(button)
|
|
92
|
+
[1, :left, (defined?(GLFW::GLFW_MOUSE_BUTTON_LEFT) && GLFW::GLFW_MOUSE_BUTTON_LEFT)].include?(button)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def sync_spherical
|
|
96
|
+
offset = camera.world_position - target
|
|
97
|
+
@radius = [offset.length, min_distance].max
|
|
98
|
+
@azimuth = Math.atan2(offset.x, offset.z)
|
|
99
|
+
@polar = Math.acos((offset.y / @radius).clamp(-1.0, 1.0))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def update_camera
|
|
103
|
+
sin_polar = Math.sin(@polar)
|
|
104
|
+
camera.position.set(
|
|
105
|
+
target.x + (@radius * sin_polar * Math.sin(@azimuth)),
|
|
106
|
+
target.y + (@radius * Math.cos(@polar)),
|
|
107
|
+
target.z + (@radius * sin_polar * Math.cos(@azimuth))
|
|
108
|
+
)
|
|
109
|
+
camera.look_at(target)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def vector(value)
|
|
113
|
+
value.is_a?(Larb::Vec3) ? value : Larb::Vec3.new(*value.to_a)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
OrbitControls = Orbit
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stagecraft
|
|
4
|
+
class Attribute
|
|
5
|
+
FORMAT_COMPONENTS = {
|
|
6
|
+
float32: 1, float32x2: 2, float32x3: 3, float32x4: 4,
|
|
7
|
+
uint16: 1, uint16x2: 2, uint16x4: 4,
|
|
8
|
+
sint16x2: 2, sint16x4: 4, snorm16x2: 2, snorm16x4: 4,
|
|
9
|
+
unorm16x2: 2, unorm16x4: 4,
|
|
10
|
+
uint32: 1, uint32x2: 2, uint32x3: 3, uint32x4: 4,
|
|
11
|
+
unorm8x2: 2, unorm8x4: 4, uint8x2: 2, uint8x4: 4,
|
|
12
|
+
sint8x2: 2, sint8x4: 4, snorm8x2: 2, snorm8x4: 4
|
|
13
|
+
}.freeze
|
|
14
|
+
FORMAT_WIDTHS = {
|
|
15
|
+
float32: 4, uint32: 4, uint16: 2,
|
|
16
|
+
uint8: 1, unorm8: 1, sint8: 1, snorm8: 1,
|
|
17
|
+
sint16: 2, snorm16: 2, unorm16: 2
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :data, :format, :count, :version
|
|
21
|
+
|
|
22
|
+
def initialize(data:, format:, count:, &on_change)
|
|
23
|
+
@format = format.to_sym
|
|
24
|
+
@count = Integer(count)
|
|
25
|
+
@version = 0
|
|
26
|
+
@on_change = on_change
|
|
27
|
+
@initialized = false
|
|
28
|
+
validate!
|
|
29
|
+
self.data = data
|
|
30
|
+
@initialized = true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def data=(value)
|
|
34
|
+
raise ArgumentError, "attribute data must be a packed String" unless value.is_a?(String)
|
|
35
|
+
|
|
36
|
+
next_data = value.b.freeze
|
|
37
|
+
expected_size = count * byte_stride
|
|
38
|
+
unless next_data.bytesize == expected_size
|
|
39
|
+
raise ArgumentError, "attribute data must contain #{expected_size} bytes, got #{next_data.bytesize}"
|
|
40
|
+
end
|
|
41
|
+
@data = next_data
|
|
42
|
+
@version += 1
|
|
43
|
+
@on_change&.call if @initialized
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def components
|
|
47
|
+
FORMAT_COMPONENTS.fetch(format) { raise ArgumentError, "unsupported vertex format #{format.inspect}" }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def byte_stride
|
|
51
|
+
prefix = FORMAT_WIDTHS.keys.find { |candidate| format.to_s.start_with?(candidate.to_s) }
|
|
52
|
+
FORMAT_WIDTHS.fetch(prefix) * components
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def validate!
|
|
58
|
+
raise ArgumentError, "attribute count must be non-negative" if count.negative?
|
|
59
|
+
|
|
60
|
+
components
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class IndexAttribute
|
|
65
|
+
attr_reader :data, :format, :count, :version
|
|
66
|
+
|
|
67
|
+
def initialize(data:, format:, &on_change)
|
|
68
|
+
@format = format.to_sym
|
|
69
|
+
raise ArgumentError, "index format must be :uint16 or :uint32" unless %i[uint16 uint32].include?(@format)
|
|
70
|
+
|
|
71
|
+
@version = 0
|
|
72
|
+
@on_change = on_change
|
|
73
|
+
@initialized = false
|
|
74
|
+
self.data = data
|
|
75
|
+
@initialized = true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def data=(value)
|
|
79
|
+
raise ArgumentError, "index data must be a packed String" unless value.is_a?(String)
|
|
80
|
+
|
|
81
|
+
next_data = value.b.freeze
|
|
82
|
+
width = format == :uint16 ? 2 : 4
|
|
83
|
+
raise ArgumentError, "index data is not aligned to #{width} bytes" unless (next_data.bytesize % width).zero?
|
|
84
|
+
|
|
85
|
+
@data = next_data
|
|
86
|
+
@count = @data.bytesize / width
|
|
87
|
+
@version += 1
|
|
88
|
+
@on_change&.call if @initialized
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|