3rb 0.1.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 (100) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +2 -0
  3. data/3rb.gemspec +29 -0
  4. data/CHANGELOG.md +12 -0
  5. data/LICENSE +21 -0
  6. data/README.md +321 -0
  7. data/Rakefile +13 -0
  8. data/examples/01_hello_cube.rb +29 -0
  9. data/examples/02_basic_geometries.rb +56 -0
  10. data/examples/03_materials.rb +61 -0
  11. data/examples/04_lighting.rb +63 -0
  12. data/examples/05_animation.rb +79 -0
  13. data/examples/06_custom_shader.rb +92 -0
  14. data/examples/07_scene_graph.rb +74 -0
  15. data/examples/08_orbit_controls.rb +50 -0
  16. data/examples/09_3d_chart.rb +71 -0
  17. data/examples/10_procedural_terrain.rb +140 -0
  18. data/examples/11_particle_system.rb +68 -0
  19. data/examples/12_model_loader.rb +73 -0
  20. data/examples/13_game_prototype.rb +145 -0
  21. data/examples/14_utah_teapot.rb +291 -0
  22. data/examples/15_stanford_bunny.rb +200 -0
  23. data/examples/16_cornell_box.rb +373 -0
  24. data/examples/17_weird_fractal4.rb +130 -0
  25. data/examples/18_platonic_solids.rb +268 -0
  26. data/lib/3rb/animation/animation_clip.rb +287 -0
  27. data/lib/3rb/animation/animation_mixer.rb +366 -0
  28. data/lib/3rb/cameras/camera.rb +50 -0
  29. data/lib/3rb/cameras/orthographic_camera.rb +92 -0
  30. data/lib/3rb/cameras/perspective_camera.rb +103 -0
  31. data/lib/3rb/controls/orbit_controls.rb +341 -0
  32. data/lib/3rb/core/buffer_attribute.rb +172 -0
  33. data/lib/3rb/core/group.rb +9 -0
  34. data/lib/3rb/core/object3d.rb +298 -0
  35. data/lib/3rb/core/scene.rb +78 -0
  36. data/lib/3rb/dsl/helpers.rb +57 -0
  37. data/lib/3rb/dsl/scene_builder.rb +288 -0
  38. data/lib/3rb/ffi/glfw.rb +61 -0
  39. data/lib/3rb/ffi/opengl.rb +137 -0
  40. data/lib/3rb/ffi/platform.rb +65 -0
  41. data/lib/3rb/geometries/box_geometry.rb +101 -0
  42. data/lib/3rb/geometries/buffer_geometry.rb +345 -0
  43. data/lib/3rb/geometries/cone_geometry.rb +29 -0
  44. data/lib/3rb/geometries/cylinder_geometry.rb +149 -0
  45. data/lib/3rb/geometries/plane_geometry.rb +75 -0
  46. data/lib/3rb/geometries/sphere_geometry.rb +93 -0
  47. data/lib/3rb/geometries/torus_geometry.rb +77 -0
  48. data/lib/3rb/lights/ambient_light.rb +9 -0
  49. data/lib/3rb/lights/directional_light.rb +57 -0
  50. data/lib/3rb/lights/hemisphere_light.rb +26 -0
  51. data/lib/3rb/lights/light.rb +27 -0
  52. data/lib/3rb/lights/point_light.rb +68 -0
  53. data/lib/3rb/lights/rect_area_light.rb +35 -0
  54. data/lib/3rb/lights/spot_light.rb +88 -0
  55. data/lib/3rb/loaders/gltf_loader.rb +304 -0
  56. data/lib/3rb/loaders/loader.rb +94 -0
  57. data/lib/3rb/loaders/obj_loader.rb +186 -0
  58. data/lib/3rb/loaders/texture_loader.rb +55 -0
  59. data/lib/3rb/materials/basic_material.rb +70 -0
  60. data/lib/3rb/materials/lambert_material.rb +102 -0
  61. data/lib/3rb/materials/material.rb +114 -0
  62. data/lib/3rb/materials/phong_material.rb +106 -0
  63. data/lib/3rb/materials/shader_material.rb +104 -0
  64. data/lib/3rb/materials/standard_material.rb +106 -0
  65. data/lib/3rb/math/color.rb +246 -0
  66. data/lib/3rb/math/euler.rb +156 -0
  67. data/lib/3rb/math/math_utils.rb +132 -0
  68. data/lib/3rb/math/matrix3.rb +269 -0
  69. data/lib/3rb/math/matrix4.rb +501 -0
  70. data/lib/3rb/math/quaternion.rb +337 -0
  71. data/lib/3rb/math/vector2.rb +216 -0
  72. data/lib/3rb/math/vector3.rb +366 -0
  73. data/lib/3rb/math/vector4.rb +233 -0
  74. data/lib/3rb/native/gl.rb +382 -0
  75. data/lib/3rb/native/native.rb +55 -0
  76. data/lib/3rb/native/window.rb +111 -0
  77. data/lib/3rb/native.rb +9 -0
  78. data/lib/3rb/objects/line.rb +116 -0
  79. data/lib/3rb/objects/mesh.rb +40 -0
  80. data/lib/3rb/objects/points.rb +71 -0
  81. data/lib/3rb/renderers/opengl_renderer.rb +567 -0
  82. data/lib/3rb/renderers/renderer.rb +60 -0
  83. data/lib/3rb/renderers/shader_lib.rb +100 -0
  84. data/lib/3rb/textures/cube_texture.rb +26 -0
  85. data/lib/3rb/textures/data_texture.rb +35 -0
  86. data/lib/3rb/textures/render_target.rb +125 -0
  87. data/lib/3rb/textures/texture.rb +190 -0
  88. data/lib/3rb/version.rb +5 -0
  89. data/lib/3rb.rb +86 -0
  90. data/shaders/basic.frag +19 -0
  91. data/shaders/basic.vert +15 -0
  92. data/shaders/common/lights.glsl +53 -0
  93. data/shaders/common/uniforms.glsl +9 -0
  94. data/shaders/lambert.frag +37 -0
  95. data/shaders/lambert.vert +22 -0
  96. data/shaders/phong.frag +51 -0
  97. data/shaders/phong.vert +28 -0
  98. data/shaders/standard.frag +92 -0
  99. data/shaders/standard.vert +28 -0
  100. metadata +155 -0
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunrb
4
+ class SpotLight < Light
5
+ attr_accessor :target
6
+ attr_accessor :distance, :angle, :penumbra, :decay
7
+ attr_accessor :shadow
8
+ attr_accessor :map
9
+
10
+ def initialize(color: 0xffffff, intensity: 1.0,
11
+ distance: 0, angle: Math::PI / 3, penumbra: 0, decay: 2)
12
+ super(color: color, intensity: intensity)
13
+ @target = Object3D.new
14
+ @distance = distance.to_f
15
+ @angle = angle.to_f
16
+ @penumbra = penumbra.to_f
17
+ @decay = decay.to_f
18
+ @map = nil
19
+ @shadow = SpotLightShadow.new
20
+ end
21
+
22
+ def power
23
+ @intensity * Math::PI
24
+ end
25
+
26
+ def power=(value)
27
+ @intensity = value / Math::PI
28
+ end
29
+
30
+ def copy(source, recursive = true)
31
+ super
32
+ @target = source.target.clone
33
+ @distance = source.distance
34
+ @angle = source.angle
35
+ @penumbra = source.penumbra
36
+ @decay = source.decay
37
+ @map = source.map
38
+ @shadow = source.shadow.clone
39
+ self
40
+ end
41
+
42
+ def clone(recursive = true)
43
+ self.class.new.copy(self, recursive)
44
+ end
45
+
46
+ def dispose
47
+ @shadow&.dispose
48
+ end
49
+
50
+ def to_h
51
+ super.merge(
52
+ target: @target.position.to_a,
53
+ distance: @distance,
54
+ angle: @angle,
55
+ penumbra: @penumbra,
56
+ decay: @decay
57
+ )
58
+ end
59
+ end
60
+
61
+ class SpotLightShadow
62
+ attr_accessor :camera, :bias, :normal_bias, :radius
63
+ attr_accessor :map_size, :focus
64
+
65
+ def initialize
66
+ @camera = PerspectiveCamera.new(fov: 50, aspect: 1, near: 0.5, far: 500)
67
+ @bias = 0
68
+ @normal_bias = 0
69
+ @radius = 1
70
+ @map_size = Vector2.new(512, 512)
71
+ @focus = 1
72
+ end
73
+
74
+ def clone
75
+ shadow = SpotLightShadow.new
76
+ shadow.camera = @camera.clone
77
+ shadow.bias = @bias
78
+ shadow.normal_bias = @normal_bias
79
+ shadow.radius = @radius
80
+ shadow.map_size = @map_size.clone
81
+ shadow.focus = @focus
82
+ shadow
83
+ end
84
+
85
+ def dispose
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "base64"
5
+
6
+ module Sunrb
7
+ class GLTFLoader < Loader
8
+ COMPONENT_TYPES = {
9
+ 5120 => :byte,
10
+ 5121 => :unsigned_byte,
11
+ 5122 => :short,
12
+ 5123 => :unsigned_short,
13
+ 5125 => :unsigned_int,
14
+ 5126 => :float
15
+ }.freeze
16
+
17
+ COMPONENT_SIZES = {
18
+ 5120 => 1, 5121 => 1,
19
+ 5122 => 2, 5123 => 2,
20
+ 5125 => 4, 5126 => 4
21
+ }.freeze
22
+
23
+ TYPE_COUNTS = {
24
+ "SCALAR" => 1,
25
+ "VEC2" => 2,
26
+ "VEC3" => 3,
27
+ "VEC4" => 4,
28
+ "MAT2" => 4,
29
+ "MAT3" => 9,
30
+ "MAT4" => 16
31
+ }.freeze
32
+
33
+ def load(url, on_load: nil, on_progress: nil, on_error: nil)
34
+ full_path = resolve_path(url)
35
+
36
+ @manager.item_start(full_path)
37
+
38
+ begin
39
+ @base_path = File.dirname(full_path)
40
+ content = File.read(full_path)
41
+ json = JSON.parse(content, symbolize_names: true)
42
+
43
+ result = parse(json)
44
+
45
+ @manager.item_end(full_path)
46
+ on_load&.call(result)
47
+
48
+ result
49
+ rescue StandardError => e
50
+ @manager.item_error(full_path)
51
+ on_error&.call(e)
52
+ nil
53
+ end
54
+ end
55
+
56
+ def parse(json)
57
+ @json = json
58
+ @buffers = load_buffers
59
+ @accessors = parse_accessors
60
+ @materials = parse_materials
61
+ @meshes = parse_meshes
62
+ @nodes = parse_nodes
63
+ @scenes = parse_scenes
64
+
65
+ {
66
+ scene: @scenes[@json[:scene] || 0],
67
+ scenes: @scenes,
68
+ animations: parse_animations,
69
+ cameras: parse_cameras,
70
+ asset: @json[:asset]
71
+ }
72
+ end
73
+
74
+ private
75
+
76
+ def resolve_path(url)
77
+ if url.start_with?("/") || url.match?(%r{^https?://})
78
+ url
79
+ else
80
+ File.join(@path, url)
81
+ end
82
+ end
83
+
84
+ def load_buffers
85
+ (@json[:buffers] || []).map do |buffer|
86
+ if buffer[:uri]&.start_with?("data:")
87
+ decode_data_uri(buffer[:uri])
88
+ elsif buffer[:uri]
89
+ File.binread(File.join(@base_path, buffer[:uri]))
90
+ else
91
+ ""
92
+ end
93
+ end
94
+ end
95
+
96
+ def decode_data_uri(uri)
97
+ match = uri.match(%r{data:[^;]*;base64,(.*)})
98
+ match ? Base64.decode64(match[1]) : ""
99
+ end
100
+
101
+ def parse_accessors
102
+ (@json[:accessors] || []).map do |accessor|
103
+ buffer_view = @json[:bufferViews][accessor[:bufferView]]
104
+ buffer = @buffers[buffer_view[:buffer]]
105
+
106
+ offset = (buffer_view[:byteOffset] || 0) + (accessor[:byteOffset] || 0)
107
+ count = accessor[:count]
108
+ type_count = TYPE_COUNTS[accessor[:type]]
109
+ component_size = COMPONENT_SIZES[accessor[:componentType]]
110
+
111
+ stride = buffer_view[:byteStride] || (type_count * component_size)
112
+
113
+ data = []
114
+ count.times do |i|
115
+ pos = offset + i * stride
116
+ type_count.times do |j|
117
+ value = unpack_component(buffer, pos + j * component_size, accessor[:componentType])
118
+ data << value
119
+ end
120
+ end
121
+
122
+ {
123
+ data: data,
124
+ count: count,
125
+ type: accessor[:type],
126
+ component_type: accessor[:componentType],
127
+ item_size: type_count
128
+ }
129
+ end
130
+ end
131
+
132
+ def unpack_component(buffer, offset, component_type)
133
+ bytes = buffer[offset, COMPONENT_SIZES[component_type]]
134
+ return 0 unless bytes
135
+
136
+ case component_type
137
+ when 5126 then bytes.unpack1("e")
138
+ when 5125 then bytes.unpack1("V")
139
+ when 5123 then bytes.unpack1("v")
140
+ when 5122 then bytes.unpack1("s<")
141
+ when 5121 then bytes.unpack1("C")
142
+ when 5120 then bytes.unpack1("c")
143
+ else 0
144
+ end
145
+ end
146
+
147
+ def parse_materials
148
+ (@json[:materials] || []).map do |mat|
149
+ pbr = mat[:pbrMetallicRoughness] || {}
150
+
151
+ base_color = pbr[:baseColorFactor] || [1, 1, 1, 1]
152
+
153
+ MeshStandardMaterial.new(
154
+ color: Color.new(base_color[0], base_color[1], base_color[2]),
155
+ metalness: pbr[:metallicFactor] || 1.0,
156
+ roughness: pbr[:roughnessFactor] || 1.0,
157
+ opacity: base_color[3],
158
+ transparent: base_color[3] < 1.0,
159
+ name: mat[:name] || ""
160
+ )
161
+ end
162
+ end
163
+
164
+ def parse_meshes
165
+ (@json[:meshes] || []).map do |mesh|
166
+ primitives = mesh[:primitives].map do |prim|
167
+ geometry = BufferGeometry.new
168
+
169
+ prim[:attributes].each do |attr_name, accessor_idx|
170
+ accessor = @accessors[accessor_idx]
171
+ attribute_name = convert_attribute_name(attr_name.to_s)
172
+ geometry.set_attribute(
173
+ attribute_name,
174
+ Float32BufferAttribute.new(accessor[:data], accessor[:item_size])
175
+ )
176
+ end
177
+
178
+ if prim[:indices]
179
+ accessor = @accessors[prim[:indices]]
180
+ geometry.set_index(accessor[:data])
181
+ end
182
+
183
+ material = prim[:material] ? @materials[prim[:material]] : MeshStandardMaterial.new
184
+
185
+ Mesh.new(geometry, material)
186
+ end
187
+
188
+ { name: mesh[:name], primitives: primitives }
189
+ end
190
+ end
191
+
192
+ def convert_attribute_name(name)
193
+ case name
194
+ when "POSITION" then :position
195
+ when "NORMAL" then :normal
196
+ when "TEXCOORD_0" then :uv
197
+ when "TEXCOORD_1" then :uv2
198
+ when "COLOR_0" then :color
199
+ when "TANGENT" then :tangent
200
+ when "JOINTS_0" then :skinIndex
201
+ when "WEIGHTS_0" then :skinWeight
202
+ else name.downcase.to_sym
203
+ end
204
+ end
205
+
206
+ def parse_nodes
207
+ (@json[:nodes] || []).map.with_index do |node, idx|
208
+ obj = if node[:mesh]
209
+ mesh_data = @meshes[node[:mesh]]
210
+ if mesh_data[:primitives].length == 1
211
+ mesh_data[:primitives].first
212
+ else
213
+ group = Group.new
214
+ mesh_data[:primitives].each { |p| group.add(p) }
215
+ group
216
+ end
217
+ else
218
+ Object3D.new
219
+ end
220
+
221
+ obj.name = node[:name] || "node_#{idx}"
222
+
223
+ if node[:translation]
224
+ obj.position.set(*node[:translation])
225
+ end
226
+
227
+ if node[:rotation]
228
+ obj.quaternion.set(*node[:rotation])
229
+ end
230
+
231
+ if node[:scale]
232
+ obj.scale.set(*node[:scale])
233
+ end
234
+
235
+ if node[:matrix]
236
+ m = Matrix4.new
237
+ m.from_array(node[:matrix])
238
+ m.decompose(obj.position, obj.quaternion, obj.scale)
239
+ end
240
+
241
+ { object: obj, children: node[:children] || [] }
242
+ end
243
+ end
244
+
245
+ def parse_scenes
246
+ (@json[:scenes] || []).map do |scene_data|
247
+ scene = Scene.new
248
+ scene.name = scene_data[:name] || ""
249
+
250
+ (scene_data[:nodes] || []).each do |node_idx|
251
+ add_node_to_parent(scene, node_idx)
252
+ end
253
+
254
+ scene
255
+ end
256
+ end
257
+
258
+ def add_node_to_parent(parent, node_idx)
259
+ node_data = @nodes[node_idx]
260
+ return unless node_data
261
+
262
+ obj = node_data[:object]
263
+ parent.add(obj)
264
+
265
+ node_data[:children].each do |child_idx|
266
+ add_node_to_parent(obj, child_idx)
267
+ end
268
+ end
269
+
270
+ def parse_animations
271
+ (@json[:animations] || []).map do |anim|
272
+ {
273
+ name: anim[:name] || "",
274
+ channels: anim[:channels],
275
+ samplers: anim[:samplers]
276
+ }
277
+ end
278
+ end
279
+
280
+ def parse_cameras
281
+ (@json[:cameras] || []).map do |cam|
282
+ if cam[:type] == "perspective"
283
+ p = cam[:perspective]
284
+ PerspectiveCamera.new(
285
+ fov: (p[:yfov] * 180 / Math::PI),
286
+ aspect: p[:aspectRatio] || 1.0,
287
+ near: p[:znear],
288
+ far: p[:zfar] || 2000
289
+ )
290
+ else
291
+ o = cam[:orthographic]
292
+ OrthographicCamera.new(
293
+ left: -o[:xmag],
294
+ right: o[:xmag],
295
+ top: o[:ymag],
296
+ bottom: -o[:ymag],
297
+ near: o[:znear],
298
+ far: o[:zfar]
299
+ )
300
+ end
301
+ end
302
+ end
303
+ end
304
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunrb
4
+ class Loader
5
+ attr_accessor :manager, :path, :resource_path, :cross_origin
6
+ attr_accessor :with_credentials, :request_header
7
+
8
+ def initialize(manager: nil)
9
+ @manager = manager || DefaultLoadingManager
10
+ @path = ""
11
+ @resource_path = ""
12
+ @cross_origin = "anonymous"
13
+ @with_credentials = false
14
+ @request_header = {}
15
+ end
16
+
17
+ def set_path(path)
18
+ @path = path
19
+ self
20
+ end
21
+
22
+ def set_resource_path(path)
23
+ @resource_path = path
24
+ self
25
+ end
26
+
27
+ def set_cross_origin(cross_origin)
28
+ @cross_origin = cross_origin
29
+ self
30
+ end
31
+
32
+ def set_with_credentials(value)
33
+ @with_credentials = value
34
+ self
35
+ end
36
+
37
+ def set_request_header(header)
38
+ @request_header = header
39
+ self
40
+ end
41
+ end
42
+
43
+ class LoadingManager
44
+ attr_accessor :on_start, :on_load, :on_progress, :on_error
45
+ attr_accessor :url_modifier
46
+
47
+ def initialize(on_load: nil, on_progress: nil, on_error: nil)
48
+ @is_loading = false
49
+ @items_loaded = 0
50
+ @items_total = 0
51
+ @url_modifier = nil
52
+
53
+ @on_start = nil
54
+ @on_load = on_load
55
+ @on_progress = on_progress
56
+ @on_error = on_error
57
+ end
58
+
59
+ def item_start(url)
60
+ @items_total += 1
61
+
62
+ unless @is_loading
63
+ @on_start&.call(url, @items_loaded, @items_total)
64
+ end
65
+
66
+ @is_loading = true
67
+ end
68
+
69
+ def item_end(url)
70
+ @items_loaded += 1
71
+ @on_progress&.call(url, @items_loaded, @items_total)
72
+
73
+ if @items_loaded == @items_total
74
+ @is_loading = false
75
+ @on_load&.call
76
+ end
77
+ end
78
+
79
+ def item_error(url)
80
+ @on_error&.call(url)
81
+ end
82
+
83
+ def resolve_url(url)
84
+ @url_modifier ? @url_modifier.call(url) : url
85
+ end
86
+
87
+ def set_url_modifier(callback)
88
+ @url_modifier = callback
89
+ self
90
+ end
91
+ end
92
+
93
+ DefaultLoadingManager = LoadingManager.new
94
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunrb
4
+ class OBJLoader < Loader
5
+ def load(url, on_load: nil, on_progress: nil, on_error: nil)
6
+ full_path = resolve_path(url)
7
+
8
+ @manager.item_start(full_path)
9
+
10
+ begin
11
+ content = File.read(full_path)
12
+ result = parse(content)
13
+
14
+ @manager.item_end(full_path)
15
+ on_load&.call(result)
16
+
17
+ result
18
+ rescue StandardError => e
19
+ @manager.item_error(full_path)
20
+ on_error&.call(e)
21
+ nil
22
+ end
23
+ end
24
+
25
+ def parse(text)
26
+ group = Group.new
27
+
28
+ vertices = []
29
+ normals = []
30
+ uvs = []
31
+
32
+ current_object = nil
33
+ current_material = nil
34
+
35
+ objects = []
36
+ materials = {}
37
+
38
+ text.each_line do |line|
39
+ line = line.strip
40
+ next if line.empty? || line.start_with?("#")
41
+
42
+ parts = line.split(/\s+/)
43
+ keyword = parts.shift
44
+
45
+ case keyword
46
+ when "o", "g"
47
+ current_object = {
48
+ name: parts.join(" "),
49
+ vertices: [],
50
+ normals: [],
51
+ uvs: [],
52
+ faces: []
53
+ }
54
+ objects << current_object
55
+
56
+ when "v"
57
+ vertices << parts[0..2].map(&:to_f)
58
+
59
+ when "vn"
60
+ normals << parts[0..2].map(&:to_f)
61
+
62
+ when "vt"
63
+ uvs << parts[0..1].map(&:to_f)
64
+
65
+ when "f"
66
+ current_object ||= { name: "", vertices: [], normals: [], uvs: [], faces: [] }
67
+ objects << current_object unless objects.include?(current_object)
68
+
69
+ face = parse_face(parts, vertices, normals, uvs)
70
+ current_object[:faces] << face.merge(material: current_material)
71
+
72
+ when "usemtl"
73
+ current_material = parts.first
74
+
75
+ when "mtllib"
76
+ # Material library reference - stored for external loading
77
+ materials[:library] = parts.first
78
+ end
79
+ end
80
+
81
+ objects.each do |obj|
82
+ mesh = build_mesh(obj)
83
+ mesh.name = obj[:name]
84
+ group.add(mesh)
85
+ end
86
+
87
+ group.user_data[:materials] = materials
88
+ group
89
+ end
90
+
91
+ private
92
+
93
+ def resolve_path(url)
94
+ if url.start_with?("/") || url.match?(%r{^https?://})
95
+ url
96
+ else
97
+ File.join(@path, url)
98
+ end
99
+ end
100
+
101
+ def parse_face(parts, vertices, normals, uvs)
102
+ face_vertices = []
103
+ face_normals = []
104
+ face_uvs = []
105
+
106
+ parts.each do |part|
107
+ indices = part.split("/").map { |i| i.empty? ? nil : i.to_i }
108
+
109
+ v_idx = indices[0]
110
+ vt_idx = indices[1]
111
+ vn_idx = indices[2]
112
+
113
+ if v_idx
114
+ idx = v_idx > 0 ? v_idx - 1 : vertices.length + v_idx
115
+ face_vertices << vertices[idx] if vertices[idx]
116
+ end
117
+
118
+ if vt_idx
119
+ idx = vt_idx > 0 ? vt_idx - 1 : uvs.length + vt_idx
120
+ face_uvs << uvs[idx] if uvs[idx]
121
+ end
122
+
123
+ if vn_idx
124
+ idx = vn_idx > 0 ? vn_idx - 1 : normals.length + vn_idx
125
+ face_normals << normals[idx] if normals[idx]
126
+ end
127
+ end
128
+
129
+ { vertices: face_vertices, normals: face_normals, uvs: face_uvs }
130
+ end
131
+
132
+ def build_mesh(obj)
133
+ positions = []
134
+ mesh_normals = []
135
+ mesh_uvs = []
136
+ indices = []
137
+
138
+ vertex_map = {}
139
+ index = 0
140
+
141
+ obj[:faces].each do |face|
142
+ face_indices = []
143
+
144
+ face[:vertices].each_with_index do |v, i|
145
+ n = face[:normals][i] || [0, 0, 1]
146
+ uv = face[:uvs][i] || [0, 0]
147
+
148
+ key = "#{v.join(",")}_#{n.join(",")}_#{uv.join(",")}"
149
+
150
+ if vertex_map[key]
151
+ face_indices << vertex_map[key]
152
+ else
153
+ positions.concat(v)
154
+ mesh_normals.concat(n)
155
+ mesh_uvs.concat(uv)
156
+ vertex_map[key] = index
157
+ face_indices << index
158
+ index += 1
159
+ end
160
+ end
161
+
162
+ triangulate(face_indices).each { |tri| indices.concat(tri) }
163
+ end
164
+
165
+ geometry = BufferGeometry.new
166
+ geometry.set_attribute(:position, Float32BufferAttribute.new(positions, 3))
167
+ geometry.set_attribute(:normal, Float32BufferAttribute.new(mesh_normals, 3))
168
+ geometry.set_attribute(:uv, Float32BufferAttribute.new(mesh_uvs, 2))
169
+ geometry.set_index(indices)
170
+
171
+ material = MeshStandardMaterial.new
172
+
173
+ Mesh.new(geometry, material)
174
+ end
175
+
176
+ def triangulate(face_indices)
177
+ return [] if face_indices.length < 3
178
+
179
+ triangles = []
180
+ (1...face_indices.length - 1).each do |i|
181
+ triangles << [face_indices[0], face_indices[i], face_indices[i + 1]]
182
+ end
183
+ triangles
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunrb
4
+ class TextureLoader < Loader
5
+ def load(url, on_load: nil, on_progress: nil, on_error: nil)
6
+ texture = Texture.new
7
+
8
+ full_path = resolve_path(url)
9
+
10
+ @manager.item_start(full_path)
11
+
12
+ begin
13
+ image_data = load_image(full_path)
14
+ texture.image = image_data
15
+ texture.needs_update = true
16
+
17
+ @manager.item_end(full_path)
18
+ on_load&.call(texture)
19
+ rescue StandardError => e
20
+ @manager.item_error(full_path)
21
+ on_error&.call(e)
22
+ return nil
23
+ end
24
+
25
+ texture
26
+ end
27
+
28
+ def load_async(url)
29
+ Thread.new { load(url) }
30
+ end
31
+
32
+ private
33
+
34
+ def resolve_path(url)
35
+ if url.start_with?("/") || url.match?(%r{^https?://})
36
+ url
37
+ else
38
+ File.join(@path, url)
39
+ end
40
+ end
41
+
42
+ def load_image(path)
43
+ raise LoaderError, "File not found: #{path}" unless File.exist?(path)
44
+
45
+ {
46
+ path: path,
47
+ data: File.binread(path),
48
+ width: nil,
49
+ height: nil
50
+ }
51
+ end
52
+ end
53
+
54
+ class LoaderError < StandardError; end
55
+ end