bestguigui_lab 0.13.0 → 0.14.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c8ae9a4f1ed462793b85d3bc6c0307ca45958e7f4cfa47e47e8714709f397e28
4
- data.tar.gz: cb1b88bb9b5fbf2e163de51e59e1bb3e787339782557a09f7194a4f5f04c610c
3
+ metadata.gz: ef2f3f88c26af654bbcc041f8bd7fc933a63c5609d747d97a7a293ddf76675ec
4
+ data.tar.gz: 788af1255f57430851b9c540cece24285f318ea815ec509b3a846ce314e0180b
5
5
  SHA512:
6
- metadata.gz: 92b8120063fa68d38ee3146420f700afa121d18cb0b701df124cc69bd893fb1d9b18bb724ba816f4938aa27371530c1188037c911b9cd421047eee639ec1477d
7
- data.tar.gz: 5eb225c89974c30a6a2cff05464d7f9b5204e9accebb5a8d7fa9a38020bff9b80ec8f987a18977ff6ceee20f62ab5b01762f802676b9de2191fd837e492af41f
6
+ metadata.gz: 54e63a9a9e6e8db8553290ee3b00dae55b774a8f935b164574af1504e5577683886bcea05f8c1a7c39b53436e479afbc5d1d7d3fe32fde3b287630027176b0fb
7
+ data.tar.gz: 941c362b66dc200c264f13fa6694a9c359f183a0de09784e4fcc1bdf438e99c11413b64d83678adc05de87c8ad3be51454c68fd3fa246d70873beb48dbc9e803
@@ -11,6 +11,7 @@ module Bestguigui
11
11
  @songs = Hash.new { |cache, path| cache[path] = Gosu::Song.new(path) }
12
12
  @fonts = Hash.new { |cache, key| cache[key] = Gosu::Font.new(key[1], name: key[0]) }
13
13
  @models = Hash.new { |cache, key| cache[key] = ObjModel.new(key[0], shading: key[1]) }
14
+ @gltf_models = Hash.new { |cache, key| cache[key] = GLTFModel.new(key[0], shading: key[1], rigid: key[2]) }
14
15
  @textures = Hash.new { |cache, path| cache[path] = GLTexture.new(path) }
15
16
  @frames = Hash.new { |cache, key| cache[key] = Gosu::Image.load_tiles(key[0], key[1], key[2], retro: true) }
16
17
 
@@ -29,6 +30,7 @@ module Bestguigui
29
30
  end
30
31
 
31
32
  def self.model(name, shading: :texture) = @models[[resolve(name), shading]]
33
+ def self.gltf_model(name, shading: :texture, rigid: false) = @gltf_models[[resolve(name), shading, rigid]]
32
34
  def self.texture(name) = @textures[resolve(name)]
33
35
  def self.frames(name, tile_width, tile_height) = @frames[[resolve(name), tile_width, tile_height]]
34
36
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bestguigui
4
+ # Un clip d'animation glTF : un nom, une duree, et des canaux — chacun
5
+ # anime UNE propriete (:translation, :rotation ou :scale) d'UN noeud.
6
+ # local_pose_at echantillonne tous les canaux a un instant donne ; ne
7
+ # renvoie que les noeuds/proprietes reellement animes ici — GLTFSkeleton
8
+ # complete le reste avec la pose de repos de chaque noeud.
9
+ class GLTFAnimation
10
+ attr_reader :name, :duration
11
+
12
+ # channels : tableau de {node:, path:, times:, values:, interpolation:}
13
+ # — times triees (secondes), values un tableau de tableaux (une entree
14
+ # par keyframe : 3 composantes pour translation/scale, 4 pour
15
+ # rotation), interpolation 'LINEAR' ou 'STEP' (le seul autre cas
16
+ # standard, CUBICSPLINE, n'est pas gere — retombe sur un LINEAR).
17
+ def initialize(name, channels)
18
+ @name = name
19
+ @channels = channels
20
+ @duration = channels.map { |channel| channel[:times].last || 0.0 }.max || 0.0
21
+ end
22
+
23
+ def local_pose_at(time)
24
+ pose = Hash.new { |h, k| h[k] = {} }
25
+ @channels.each { |channel| pose[channel[:node]][channel[:path]] = sample_channel(channel, time) }
26
+ pose
27
+ end
28
+
29
+ private
30
+
31
+ def sample_channel(channel, time)
32
+ times = channel[:times]
33
+ values = channel[:values]
34
+ return values.first if times.size == 1 || time <= times.first
35
+ return values.last if time >= times.last
36
+
37
+ index = times.rindex { |t| t <= time }
38
+ interpolate(channel, values[index], values[index + 1], segment_progress(times, index, time))
39
+ end
40
+
41
+ def segment_progress(times, index, time)
42
+ span = times[index + 1] - times[index]
43
+ span.zero? ? 0.0 : (time - times[index]) / span
44
+ end
45
+
46
+ def interpolate(channel, from, dest, progress)
47
+ return from if channel[:interpolation] == 'STEP'
48
+ return GLTFMath.quat_slerp(from, dest, progress) if channel[:path] == :rotation
49
+
50
+ GLTFMath.vec3_lerp(from, dest, progress)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bestguigui
4
+ # Lit le temps d'une ou deux GLTFAnimation (pendant une transition) et
5
+ # renvoie la pose locale resultante — fondu enchaine entre les deux le
6
+ # temps de la transition, jamais de coupure nette d'une animation a
7
+ # l'autre. GLTFModel#play/#update s'en servent directement.
8
+ class GLTFAnimator
9
+ attr_accessor :speed
10
+
11
+ def initialize(speed: 1.0)
12
+ @current = nil
13
+ @time = 0.0
14
+ @loop = true
15
+ @previous = nil
16
+ @previous_time = 0.0
17
+ @previous_loop = true
18
+ @transition_duration = 0.0
19
+ @transition_elapsed = 0.0
20
+ @speed = speed
21
+ end
22
+
23
+ # loop: rejoue en boucle une fois la fin atteinte. transition: duree
24
+ # (secondes) du fondu enchaine depuis l'animation en cours ; 0 = coupe
25
+ # net (par defaut pour la toute premiere animation, rien avant elle).
26
+ def play(animation, loop: true, transition: 0.0)
27
+ transitioning = transition.positive? && @current
28
+ @previous = transitioning ? @current : nil
29
+ @previous_time = @time
30
+ @previous_loop = @loop
31
+ @current = animation
32
+ @loop = loop
33
+ @time = 0.0
34
+ @transition_duration = transition
35
+ @transition_elapsed = 0.0
36
+ end
37
+
38
+ def update(dt)
39
+ return unless @current
40
+
41
+ @time = advance_time(@time, dt * @speed, @current, @loop)
42
+ return unless @previous
43
+
44
+ @previous_time = advance_time(@previous_time, dt * @speed, @previous, @previous_loop)
45
+ @transition_elapsed += dt
46
+ @previous = nil if @transition_elapsed >= @transition_duration
47
+ end
48
+
49
+ # Pose locale courante, fondue enchainee si une transition est en
50
+ # cours — Hash node => {translation:/rotation:/scale: => [...]},
51
+ # partiel (seuls les noeuds/proprietes reellement animes ici).
52
+ def pose
53
+ return {} unless @current
54
+
55
+ current_pose = @current.local_pose_at(@time)
56
+ return current_pose unless @previous
57
+
58
+ blend_t = @transition_duration.zero? ? 1.0 : (@transition_elapsed / @transition_duration).clamp(0.0, 1.0)
59
+ blend_poses(@previous.local_pose_at(@previous_time), current_pose, blend_t)
60
+ end
61
+
62
+ private
63
+
64
+ def advance_time(time, dt, animation, loop)
65
+ time += dt
66
+ return time % animation.duration if loop && animation.duration.positive?
67
+
68
+ [time, animation.duration].min
69
+ end
70
+
71
+ def blend_poses(from_pose, dest_pose, progress)
72
+ nodes = from_pose.keys | dest_pose.keys
73
+ nodes.to_h { |node| [node, blend_node(from_pose[node], dest_pose[node], progress)] }
74
+ end
75
+
76
+ def blend_node(from, dest, progress)
77
+ from ||= {}
78
+ dest ||= {}
79
+ (from.keys | dest.keys).to_h { |path| [path, blend_property(path, from[path], dest[path], progress)] }
80
+ end
81
+
82
+ # Si une seule des deux animations definit cette propriete pour ce
83
+ # noeud, pas de vraie valeur "avant" a interpoler — on la garde telle
84
+ # quelle plutot que d'inventer une pose de depart arbitraire.
85
+ def blend_property(path, from, dest, progress)
86
+ return dest unless from
87
+ return from unless dest
88
+ return GLTFMath.quat_slerp(from, dest, progress) if path == :rotation
89
+
90
+ GLTFMath.vec3_lerp(from, dest, progress)
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Bestguigui
6
+ # Parse un conteneur .glb (glTF binaire, un seul fichier auto-suffisant) :
7
+ # en-tete (magic/version/longueur) puis des chunks JSON et BIN — le seul
8
+ # buffer supporte est celui-la, embarque, jamais un fichier .bin externe
9
+ # reference par URI. #read_accessor lit un tableau glTF (positions,
10
+ # indices, temps de keyframes...) depuis le chunk BIN, en suivant
11
+ # bufferView/accessor comme le decrit la specification.
12
+ class GLTFBuffer
13
+ # component type (glTF) => [directive String#unpack, octets]
14
+ COMPONENT_TYPES = {
15
+ 5120 => ['c', 1], 5121 => ['C', 1], 5122 => ['s<', 2],
16
+ 5123 => ['v', 2], 5125 => ['V', 4], 5126 => ['e', 4]
17
+ }.freeze
18
+ TYPE_COMPONENT_COUNTS = { 'SCALAR' => 1, 'VEC2' => 2, 'VEC3' => 3, 'VEC4' => 4, 'MAT4' => 16 }.freeze
19
+
20
+ attr_reader :json
21
+
22
+ def initialize(path)
23
+ chunks = read_chunks(File.binread(path))
24
+ @json = JSON.parse(chunks.fetch('JSON'))
25
+ @bin = chunks['BIN']
26
+ end
27
+
28
+ def read_accessor(index)
29
+ accessor = @json['accessors'][index]
30
+ format, size, components, stride, base = accessor_layout(accessor)
31
+
32
+ Array.new(accessor['count']) { |i| read_element(base + (i * stride), format, size, components) }
33
+ end
34
+
35
+ # Sous-chaine brute du chunk BIN, pour une image embarquee (cf.
36
+ # GLTFModel#load_image) — pas un accessor, directement bufferView.
37
+ def read_buffer_view(index)
38
+ view = @json['bufferViews'][index]
39
+ @bin[(view['byteOffset'] || 0), view['byteLength']]
40
+ end
41
+
42
+ private
43
+
44
+ # format/size (cf. COMPONENT_TYPES), nombre de composantes (cf.
45
+ # TYPE_COMPONENT_COUNTS), pas et offset de depart en octets dans le
46
+ # chunk BIN pour cet accessor.
47
+ def accessor_layout(accessor)
48
+ view = @json['bufferViews'][accessor['bufferView']]
49
+ format, size = COMPONENT_TYPES.fetch(accessor['componentType'])
50
+ components = TYPE_COMPONENT_COUNTS.fetch(accessor['type'])
51
+ stride = view['byteStride'] || (size * components)
52
+ base = (view['byteOffset'] || 0) + (accessor['byteOffset'] || 0)
53
+ [format, size, components, stride, base]
54
+ end
55
+
56
+ def read_chunks(data)
57
+ chunks = {}
58
+ offset = 12 # apres l'en-tete de 12 octets (magic, version, longueur totale)
59
+ while offset < data.bytesize
60
+ chunk_length, chunk_type = data[offset, 8].unpack('Va4')
61
+ chunks[chunk_type.delete("\x00")] = data[offset + 8, chunk_length]
62
+ offset += 8 + chunk_length
63
+ end
64
+ chunks
65
+ end
66
+
67
+ def read_element(offset, format, size, components)
68
+ values = @bin[offset, size * components].unpack("#{format}#{components}")
69
+ components == 1 ? values.first : values
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bestguigui
4
+ # Petite boite a outils matrices 4x4 / quaternions pour GLTFModel —
5
+ # matrices colonne-majeur (convention glTF). Jamais envoyees a la pile
6
+ # OpenGL (glLoadMatrixf) : uniquement du calcul CPU pour le skinning
7
+ # (voir GLTFPrimitive#skin!), donc aucune contrainte de convention avec
8
+ # le reste du kit (Render3D/ObjModel s'appuient sur glTranslatef/
9
+ # glRotatef, jamais sur des matrices explicites).
10
+ # rubocop:disable Naming/MethodParameterName, Metrics/AbcSize, Style/ParallelAssignment
11
+ # -- noms mathematiques usuels (a, b, m, p, q, r, s, t, v...) et arithmetique
12
+ # vectorielle deroulee (pas de boucle a extraire) partout dans cette classe.
13
+ class GLTFMath
14
+ def self.identity
15
+ [
16
+ 1.0, 0.0, 0.0, 0.0,
17
+ 0.0, 1.0, 0.0, 0.0,
18
+ 0.0, 0.0, 1.0, 0.0,
19
+ 0.0, 0.0, 0.0, 1.0
20
+ ]
21
+ end
22
+
23
+ # Compose T * R * S : t/s par defaut [0,0,0]/[1,1,1], r (quaternion
24
+ # [x, y, z, w]) par defaut la rotation identite.
25
+ def self.matrix_from_trs(t, r, s)
26
+ m = quat_to_matrix(r || [0.0, 0.0, 0.0, 1.0])
27
+ scale = s || [1.0, 1.0, 1.0]
28
+ (0..2).each { |col| (0..2).each { |row| m[(col * 4) + row] *= scale[col] } }
29
+ m[12], m[13], m[14] = t || [0.0, 0.0, 0.0]
30
+ m
31
+ end
32
+
33
+ def self.quat_to_matrix(q)
34
+ x, y, z, w = q
35
+ xx, yy, zz = x * x, y * y, z * z
36
+ xy, xz, yz = x * y, x * z, y * z
37
+ wx, wy, wz = w * x, w * y, w * z
38
+ [
39
+ 1 - (2 * (yy + zz)), 2 * (xy + wz), 2 * (xz - wy), 0.0,
40
+ 2 * (xy - wz), 1 - (2 * (xx + zz)), 2 * (yz + wx), 0.0,
41
+ 2 * (xz + wy), 2 * (yz - wx), 1 - (2 * (xx + yy)), 0.0,
42
+ 0.0, 0.0, 0.0, 1.0
43
+ ]
44
+ end
45
+
46
+ # c = a * b (b applique en premier).
47
+ def self.multiply(a, b)
48
+ Array.new(16) do |i|
49
+ col = i / 4
50
+ row = i % 4
51
+ (0..3).sum { |k| a[(k * 4) + row] * b[(col * 4) + k] }
52
+ end
53
+ end
54
+
55
+ def self.transform_point(m, p)
56
+ x, y, z = p
57
+ [
58
+ (m[0] * x) + (m[4] * y) + (m[8] * z) + m[12],
59
+ (m[1] * x) + (m[5] * y) + (m[9] * z) + m[13],
60
+ (m[2] * x) + (m[6] * y) + (m[10] * z) + m[14]
61
+ ]
62
+ end
63
+
64
+ # Comme transform_point, sans la translation — pour une normale.
65
+ # Simplification : ignore l'inverse-transposee (correcte pour une
66
+ # echelle uniforme, l'essentiel des rigs de jam ; peut legerement
67
+ # deformer une normale sous une echelle non uniforme sur un joint).
68
+ def self.transform_vector(m, v)
69
+ x, y, z = v
70
+ [
71
+ (m[0] * x) + (m[4] * y) + (m[8] * z),
72
+ (m[1] * x) + (m[5] * y) + (m[9] * z),
73
+ (m[2] * x) + (m[6] * y) + (m[10] * z)
74
+ ]
75
+ end
76
+
77
+ def self.vec3_lerp(a, b, t) = (0..2).map { |i| a[i] + ((b[i] - a[i]) * t) }
78
+
79
+ # Interpolation spherique entre deux quaternions [x, y, z, w] ; bascule
80
+ # sur un lerp normalise si l'angle est trop petit pour diviser dessus.
81
+ def self.quat_slerp(a, b, t)
82
+ dot = (0..3).sum { |i| a[i] * b[i] }
83
+ b, dot = negate(b), -dot if dot.negative?
84
+ return normalize_quat(vec4_lerp(a, b, t)) if dot > 0.9995
85
+
86
+ slerp_by_angle(a, b, dot, t)
87
+ end
88
+
89
+ def self.vec4_lerp(a, b, t) = (0..3).map { |i| a[i] + ((b[i] - a[i]) * t) }
90
+
91
+ def self.negate(v) = v.map(&:-@)
92
+
93
+ def self.normalize_quat(q)
94
+ length = Math.sqrt(q.sum { |c| c * c })
95
+ q.map { |c| c / length }
96
+ end
97
+
98
+ def self.slerp_by_angle(a, b, dot, t)
99
+ theta0 = Math.acos(dot.clamp(-1.0, 1.0))
100
+ sin_theta0 = Math.sin(theta0)
101
+ theta = theta0 * t
102
+ s0 = Math.cos(theta) - (dot * Math.sin(theta) / sin_theta0)
103
+ s1 = Math.sin(theta) / sin_theta0
104
+ (0..3).map { |i| (a[i] * s0) + (b[i] * s1) }
105
+ end
106
+
107
+ # Inverse generale d'une matrice 4x4, par elimination de Gauss-Jordan
108
+ # — necessaire pour repasser de l'espace monde d'un noeud de maillage
109
+ # a son espace local (cf. GLTFSkeleton#joint_matrices). Rare dans la
110
+ # boucle de rendu (quelques appels par frame, pas par sommet) : la
111
+ # simplicite prime ici sur la performance.
112
+ # rubocop:disable-next Metrics/MethodLength -- élimination de Gauss-Jordan, ne se découpe pas proprement
113
+ def self.invert(m)
114
+ rows = m.each_slice(4).to_a.transpose.map(&:dup)
115
+ inverse = Array.new(4) { |i| Array.new(4) { |j| i == j ? 1.0 : 0.0 } }
116
+
117
+ 4.times do |col|
118
+ pivot_row = (col...4).max_by { |r| rows[r][col].abs }
119
+ rows[col], rows[pivot_row] = rows[pivot_row], rows[col]
120
+ inverse[col], inverse[pivot_row] = inverse[pivot_row], inverse[col]
121
+
122
+ pivot = rows[col][col]
123
+ raise ArgumentError, 'matrice non inversible' if pivot.abs < 1e-9
124
+
125
+ 4.times do |k|
126
+ rows[col][k] /= pivot
127
+ inverse[col][k] /= pivot
128
+ end
129
+ eliminate_column(rows, inverse, col)
130
+ end
131
+
132
+ inverse.transpose.flatten
133
+ end
134
+
135
+ def self.eliminate_column(rows, inverse, col)
136
+ 4.times do |row|
137
+ next if row == col
138
+
139
+ factor = rows[row][col]
140
+ 4.times do |k|
141
+ rows[row][k] -= factor * rows[col][k]
142
+ inverse[row][k] -= factor * inverse[col][k]
143
+ end
144
+ end
145
+ end
146
+ end
147
+ # rubocop:enable Naming/MethodParameterName, Metrics/AbcSize, Style/ParallelAssignment
148
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+
5
+ module Bestguigui
6
+ # Charge un modele .glb (glTF binaire auto-suffisant, cf. GLTFBuffer) :
7
+ # squelette + animations (GLTFSkeleton/GLTFAnimation/GLTFAnimator),
8
+ # skinning CPU (GLTFPrimitive), shading: :texture ou :gouraud, rigid:
9
+ # true pour ignorer la mise a l'echelle des os.
10
+ #
11
+ # model = Bestguigui::Assets.gltf_model('assets/jill.glb')
12
+ # model.play('Idle')
13
+ # model.update(dt) # puis model.draw, dans ta Scene
14
+ # model.play('walk', transition: 0.3)
15
+ class GLTFModel
16
+ def initialize(path, shading: :texture, rigid: false, speed: 1.0)
17
+ super()
18
+ @shading = shading
19
+ @rigid = rigid
20
+ load_scene(path)
21
+ @animator = GLTFAnimator.new(speed: speed)
22
+ reskin
23
+ end
24
+
25
+ def animation_names = @animations.keys
26
+ def speed = @animator.speed
27
+
28
+ def speed=(value)
29
+ @animator.speed = value
30
+ end
31
+
32
+ def play(name, loop: true, transition: 0.0)
33
+ animation = @animations.fetch(name) { raise ArgumentError, "animation glTF inconnue : #{name.inspect}" }
34
+ @animator.play(animation, loop: loop, transition: transition)
35
+ end
36
+
37
+ def update(dt)
38
+ @animator.update(dt)
39
+ reskin
40
+ end
41
+
42
+ def draw
43
+ @primitives.each(&:draw)
44
+ end
45
+
46
+ private
47
+
48
+ def load_scene(path)
49
+ @buffer = GLTFBuffer.new(path)
50
+ @json = @buffer.json
51
+ @textures = (@json['images'] || []).map { |image| load_image(image) }
52
+ @skeleton = build_skeleton
53
+ @animations = build_animations
54
+ @primitives = build_primitives
55
+ end
56
+
57
+ def reskin
58
+ pose = @animator.pose
59
+ @primitives.each { |primitive| primitive.skin!(@skeleton.joint_matrices(pose, primitive.mesh_node)) }
60
+ end
61
+
62
+ # --- Squelette (un seul skin, cf. GLTFSkeleton) ---
63
+
64
+ def build_skeleton
65
+ skin = @json['skins'][0]
66
+ inverse_bind_matrices = @buffer.read_accessor(skin['inverseBindMatrices'])
67
+ GLTFSkeleton.new(@json['nodes'], skin['joints'], inverse_bind_matrices, rigid: @rigid)
68
+ end
69
+
70
+ # --- Animations (cf. GLTFAnimation) ---
71
+
72
+ def build_animations
73
+ (@json['animations'] || []).to_h do |animation|
74
+ channels = animation['channels'].map { |channel| build_channel(channel, animation['samplers']) }
75
+ [animation['name'], GLTFAnimation.new(animation['name'], channels)]
76
+ end
77
+ end
78
+
79
+ def build_channel(channel, samplers)
80
+ sampler = samplers[channel['sampler']]
81
+ {
82
+ node: channel['target']['node'],
83
+ path: channel['target']['path'].to_sym,
84
+ times: @buffer.read_accessor(sampler['input']),
85
+ values: @buffer.read_accessor(sampler['output']),
86
+ interpolation: sampler['interpolation'] || 'LINEAR'
87
+ }
88
+ end
89
+
90
+ # --- Maillages (cf. GLTFPrimitive) ---
91
+
92
+ def build_primitives
93
+ primitives = []
94
+ @json['nodes'].each_with_index do |node, node_index|
95
+ next unless node['mesh']
96
+
97
+ @json['meshes'][node['mesh']]['primitives'].each do |primitive|
98
+ primitives << build_primitive(primitive, node_index)
99
+ end
100
+ end
101
+ primitives
102
+ end
103
+
104
+ def build_primitive(primitive, node_index)
105
+ attributes = primitive['attributes']
106
+ geometry = {
107
+ positions: @buffer.read_accessor(attributes['POSITION']),
108
+ normals: @buffer.read_accessor(attributes['NORMAL']),
109
+ texcoords: @buffer.read_accessor(attributes['TEXCOORD_0']),
110
+ joints: @buffer.read_accessor(attributes['JOINTS_0']),
111
+ weights: @buffer.read_accessor(attributes['WEIGHTS_0']),
112
+ indices: @buffer.read_accessor(primitive['indices'])
113
+ }
114
+ GLTFPrimitive.new(geometry, primitive_texture(primitive['material']), node_index, shading: @shading)
115
+ end
116
+
117
+ def primitive_texture(material_index)
118
+ return nil unless material_index
119
+
120
+ texture_ref = @json['materials'][material_index].dig('pbrMetallicRoughness', 'baseColorTexture')
121
+ texture_ref && @textures[@json['textures'][texture_ref['index']]['source']]
122
+ end
123
+
124
+ # --- Texture embarquee : ecrite dans un fichier temporaire le temps
125
+ # du chargement (Gosu::Image.new ne sait lire qu'un chemin de
126
+ # fichier, pas un blob PNG/JPEG deja en memoire). Le fichier est
127
+ # ferme AVANT que Gosu ne le lise puis explicitement supprime juste
128
+ # apres : sous Windows, un fichier encore ouvert en ecriture ne peut
129
+ # pas etre rouvert en lecture par un autre appel (contrairement a
130
+ # POSIX, ou Tempfile.create avec bloc aurait suffi).
131
+ def load_image(image_json)
132
+ return nil unless image_json['bufferView']
133
+
134
+ bytes = @buffer.read_buffer_view(image_json['bufferView'])
135
+ extension = image_json['mimeType'] == 'image/jpeg' ? '.jpg' : '.png'
136
+ write_temp_texture(bytes, extension)
137
+ end
138
+
139
+ def write_temp_texture(bytes, extension)
140
+ file = Tempfile.new(['gltf_texture', extension])
141
+ file.binmode
142
+ file.write(bytes)
143
+ file.close
144
+ GLTexture.new(file.path)
145
+ ensure
146
+ file&.unlink
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bestguigui
4
+ # Un sous-maillage skinne d'un GLTFModel : geometrie de repos (espace du
5
+ # noeud squelette — position/normale/UV/joints/poids), indices et
6
+ # texture. #skin! recalcule les sommets deformes sur le CPU (aucun
7
+ # shader dans ce kit — meme philosophie que GouraudShading) et les
8
+ # renvoie au VBO ; #draw les affiche.
9
+ class GLTFPrimitive
10
+ FLOATS_PER_VERTEX = 8 # position (3) + texcoord (2) + normale ou teinte Gouraud (3)
11
+ STRIDE = FLOATS_PER_VERTEX * 4 # 4 octets par float
12
+
13
+ attr_reader :mesh_node
14
+
15
+ # geometry : {positions:, normals:, texcoords:, joints:, weights:,
16
+ # indices:} (tableaux deja decodes par GLTFModel). mesh_node : indice
17
+ # du noeud qui porte ce maillage (cf. GLTFSkeleton#joint_matrices).
18
+ def initialize(geometry, texture, mesh_node, shading: :texture)
19
+ assign_geometry(geometry)
20
+ @texture = texture || GLTexture.blank
21
+ @mesh_node = mesh_node
22
+ @gouraud = GouraudShading.new if shading == :gouraud
23
+ @material_color = [1.0, 1.0, 1.0]
24
+ @vertex_count = @positions.length
25
+ @vbo = GLBuffer.upload(Array.new(@vertex_count * FLOATS_PER_VERTEX, 0.0).pack('F*'))
26
+ @ibo = upload_index_buffer
27
+ end
28
+
29
+ # joint_matrices : une matrice par joint (meme ordre que WEIGHTS_0/
30
+ # JOINTS_0, cf. GLTFSkeleton#joint_matrices). A appeler avant chaque
31
+ # draw ou la pose a change (typiquement une fois par frame, depuis
32
+ # GLTFModel#update).
33
+ def skin!(joint_matrices)
34
+ data = Array.new(@vertex_count) { |i| skin_vertex(i, joint_matrices) }.flatten.pack('F*')
35
+ glBindBuffer(GL_ARRAY_BUFFER, @vbo)
36
+ glBufferSubData(GL_ARRAY_BUFFER, 0, data.bytesize, data)
37
+ end
38
+
39
+ def draw
40
+ glEnable(GL_TEXTURE_2D)
41
+ glBindTexture(GL_TEXTURE_2D, @texture.tex_name)
42
+ glBindBuffer(GL_ARRAY_BUFFER, @vbo)
43
+ glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, @ibo)
44
+
45
+ enable_vertex_arrays
46
+ glDrawElements(GL_TRIANGLES, @indices.length, GL_UNSIGNED_INT, 0)
47
+ disable_vertex_arrays
48
+
49
+ glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0)
50
+ glBindBuffer(GL_ARRAY_BUFFER, 0)
51
+ glDisable(GL_TEXTURE_2D)
52
+ end
53
+
54
+ private
55
+
56
+ def assign_geometry(geometry)
57
+ @positions = geometry[:positions]
58
+ @normals = geometry[:normals]
59
+ @texcoords = geometry[:texcoords]
60
+ @joints = geometry[:joints]
61
+ @weights = geometry[:weights]
62
+ @indices = geometry[:indices]
63
+ end
64
+
65
+ def upload_index_buffer
66
+ name_buffer = "\x00" * 4
67
+ glGenBuffers(1, name_buffer)
68
+ ibo = name_buffer.unpack1('L')
69
+
70
+ data = @indices.pack('L*')
71
+ glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo)
72
+ glBufferData(GL_ELEMENT_ARRAY_BUFFER, data.bytesize, data, GL_STATIC_DRAW)
73
+ ibo
74
+ end
75
+
76
+ # Position/normale deformees par la moyenne ponderee (jusqu'a 4
77
+ # joints par sommet) des matrices de skinning courantes — le calcul
78
+ # classique du "linear blend skinning". extra est la normale
79
+ # deformee (shading: :texture) ou une teinte Gouraud recalculee a
80
+ # partir d'elle (shading: :gouraud, cf. GouraudShading) : la lumiere
81
+ # simulee suit donc l'animation, pas seulement la pose de repos.
82
+ def skin_vertex(index, joint_matrices)
83
+ joints = @joints[index]
84
+ weights = @weights[index]
85
+ position = blend_vector(@positions[index], joints, weights, joint_matrices) do |m, v|
86
+ GLTFMath.transform_point(m, v)
87
+ end
88
+ normal = blend_vector(@normals[index], joints, weights, joint_matrices) do |m, v|
89
+ GLTFMath.transform_vector(m, v)
90
+ end
91
+ extra = @gouraud ? @gouraud.vertex_color(normal, @material_color) : normal
92
+
93
+ position + @texcoords[index] + extra
94
+ end
95
+
96
+ def blend_vector(base, joints, weights, matrices)
97
+ result = [0.0, 0.0, 0.0]
98
+ joints.each_with_index do |joint_index, k|
99
+ weight = weights[k]
100
+ next if weight.zero?
101
+
102
+ transformed = yield(matrices[joint_index], base)
103
+ 3.times { |i| result[i] += transformed[i] * weight }
104
+ end
105
+ result
106
+ end
107
+
108
+ def enable_vertex_arrays
109
+ glEnableClientState(GL_VERTEX_ARRAY)
110
+ glEnableClientState(GL_TEXTURE_COORD_ARRAY)
111
+ glVertexPointer(3, GL_FLOAT, STRIDE, 0)
112
+ glTexCoordPointer(2, GL_FLOAT, STRIDE, 3 * 4)
113
+ @gouraud ? enable_color_array : enable_normal_array
114
+ end
115
+
116
+ def enable_color_array
117
+ glEnableClientState(GL_COLOR_ARRAY)
118
+ glColorPointer(3, GL_FLOAT, STRIDE, 5 * 4)
119
+ end
120
+
121
+ def enable_normal_array
122
+ glEnableClientState(GL_NORMAL_ARRAY)
123
+ glNormalPointer(GL_FLOAT, STRIDE, 5 * 4)
124
+ end
125
+
126
+ def disable_vertex_arrays
127
+ glDisableClientState(@gouraud ? GL_COLOR_ARRAY : GL_NORMAL_ARRAY)
128
+ glDisableClientState(GL_TEXTURE_COORD_ARRAY)
129
+ glDisableClientState(GL_VERTEX_ARRAY)
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bestguigui
4
+ # Hierarchie de noeuds glTF (pose de repos + parente) et son unique skin
5
+ # (liste de joints + matrices d'inverse bind pose, deja decodees par
6
+ # GLTFModel) : calcule, a partir d'une pose partielle (cf.
7
+ # GLTFAnimator#pose), la matrice monde de chaque noeud puis la matrice de
8
+ # skinning finale de chaque joint pour un noeud de maillage donne.
9
+ #
10
+ # Un seul skin gere (celui d'indice 0) : suffisant pour un personnage,
11
+ # pas pour plusieurs squelettes independants dans le meme fichier.
12
+ class GLTFSkeleton
13
+ Node = Struct.new(:translation, :rotation, :scale, :children, :parent)
14
+
15
+ # rigid: true ignore la mise a l'echelle de CHAQUE noeud (pose de
16
+ # repos ET animee) — seules rotation et translation bougent les os.
17
+ # Utile pour un squelette assemble a la main a partir de petits
18
+ # morceaux, ou une mise a l'echelle (voulue ou juste un artefact
19
+ # d'export, ex. 0.999998 au lieu de 1) etirerait le maillage au lieu
20
+ # de simplement l'articuler.
21
+ def initialize(nodes_json, joints, inverse_bind_matrices, rigid: false)
22
+ @nodes = nodes_json.map do |n|
23
+ Node.new(n['translation'], n['rotation'], n['scale'], n['children'] || [], nil)
24
+ end
25
+ @nodes.each_with_index { |node, i| node.children.each { |c| @nodes[c].parent = i } }
26
+ @joints = joints
27
+ @inverse_bind_matrices = inverse_bind_matrices
28
+ @rigid = rigid
29
+ end
30
+
31
+ # Une matrice de skinning par joint (meme ordre que WEIGHTS_0/
32
+ # JOINTS_0), pour un maillage porte par le noeud mesh_node_index.
33
+ def joint_matrices(pose, mesh_node_index)
34
+ world = world_matrices(pose)
35
+ mesh_inverse = GLTFMath.invert(world[mesh_node_index])
36
+
37
+ @joints.each_with_index.map do |joint_node, i|
38
+ skinned = GLTFMath.multiply(mesh_inverse, world[joint_node])
39
+ GLTFMath.multiply(skinned, @inverse_bind_matrices[i])
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def world_matrices(pose)
46
+ world = Array.new(@nodes.length)
47
+ @nodes.each_index { |i| compute_world_matrix(i, pose, world) }
48
+ world
49
+ end
50
+
51
+ def compute_world_matrix(index, pose, world)
52
+ return world[index] if world[index]
53
+
54
+ local = local_matrix(index, pose)
55
+ parent = @nodes[index].parent
56
+ world[index] = parent ? GLTFMath.multiply(compute_world_matrix(parent, pose, world), local) : local
57
+ end
58
+
59
+ # Fusionne, composante par composante (translation/rotation/scale),
60
+ # la pose animee (si ce noeud/cette composante y figure) avec la pose
61
+ # de repos du noeud — cf. GLTFAnimation, seules les composantes
62
+ # reellement animees sont presentes dans la pose.
63
+ def local_matrix(index, pose)
64
+ node = @nodes[index]
65
+ override = pose[index] || {}
66
+ GLTFMath.matrix_from_trs(
67
+ override[:translation] || node.translation,
68
+ override[:rotation] || node.rotation,
69
+ @rigid ? nil : (override[:scale] || node.scale)
70
+ )
71
+ end
72
+ end
73
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bestguigui
4
- VERSION = '0.13.0'
4
+ VERSION = '0.14.1'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bestguigui_lab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.0
4
+ version: 0.14.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bestguigui
@@ -47,6 +47,13 @@ files:
47
47
  - lib/bestguigui_lab/assets/splash_jingle.wav
48
48
  - lib/bestguigui_lab/gl_buffer.rb
49
49
  - lib/bestguigui_lab/gl_texture.rb
50
+ - lib/bestguigui_lab/gltf_animation.rb
51
+ - lib/bestguigui_lab/gltf_animator.rb
52
+ - lib/bestguigui_lab/gltf_buffer.rb
53
+ - lib/bestguigui_lab/gltf_math.rb
54
+ - lib/bestguigui_lab/gltf_model.rb
55
+ - lib/bestguigui_lab/gltf_primitive.rb
56
+ - lib/bestguigui_lab/gltf_skeleton.rb
50
57
  - lib/bestguigui_lab/gouraud_shading.rb
51
58
  - lib/bestguigui_lab/helpers.rb
52
59
  - lib/bestguigui_lab/image_pixels.rb