bestguigui_lab 0.13.0 → 0.14.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c8ae9a4f1ed462793b85d3bc6c0307ca45958e7f4cfa47e47e8714709f397e28
4
- data.tar.gz: cb1b88bb9b5fbf2e163de51e59e1bb3e787339782557a09f7194a4f5f04c610c
3
+ metadata.gz: d384a7e131d476249187d97515f7f62f9c165702063908cfb7852ef261300d6c
4
+ data.tar.gz: 523552046db0a7e19c10556a4782ad37ad8ee272053d037345985717ed698fe1
5
5
  SHA512:
6
- metadata.gz: 92b8120063fa68d38ee3146420f700afa121d18cb0b701df124cc69bd893fb1d9b18bb724ba816f4938aa27371530c1188037c911b9cd421047eee639ec1477d
7
- data.tar.gz: 5eb225c89974c30a6a2cff05464d7f9b5204e9accebb5a8d7fa9a38020bff9b80ec8f987a18977ff6ceee20f62ab5b01762f802676b9de2191fd837e492af41f
6
+ metadata.gz: ed74df8e29395e4545125d07f8a53d17a927b59d2fe235f3c8ac75d22dcc0d6e0ff912775b03c2c9f3a0799c369c3d24e0c05c1ed466bbea2842c79fbe42b09c
7
+ data.tar.gz: 1441f99ec534d3cade5502c36c1c1ebcc6a3ddfaeda66ac1affd710405490f7a01f67a986242cf2b1440d6d5c161bd050a81c7993e5b1235644df311738b7238
@@ -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,90 @@
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
+ def initialize
10
+ @current = nil
11
+ @time = 0.0
12
+ @loop = true
13
+ @previous = nil
14
+ @previous_time = 0.0
15
+ @previous_loop = true
16
+ @transition_duration = 0.0
17
+ @transition_elapsed = 0.0
18
+ end
19
+
20
+ # loop: rejoue en boucle une fois la fin atteinte. transition: duree
21
+ # (secondes) du fondu enchaine depuis l'animation en cours ; 0 = coupe
22
+ # net (par defaut pour la toute premiere animation, rien avant elle).
23
+ def play(animation, loop: true, transition: 0.0)
24
+ transitioning = transition.positive? && @current
25
+ @previous = transitioning ? @current : nil
26
+ @previous_time = @time
27
+ @previous_loop = @loop
28
+ @current = animation
29
+ @loop = loop
30
+ @time = 0.0
31
+ @transition_duration = transition
32
+ @transition_elapsed = 0.0
33
+ end
34
+
35
+ def update(dt)
36
+ return unless @current
37
+
38
+ @time = advance_time(@time, dt, @current, @loop)
39
+ return unless @previous
40
+
41
+ @previous_time = advance_time(@previous_time, dt, @previous, @previous_loop)
42
+ @transition_elapsed += dt
43
+ @previous = nil if @transition_elapsed >= @transition_duration
44
+ end
45
+
46
+ # Pose locale courante, fondue enchainee si une transition est en
47
+ # cours — Hash node => {translation:/rotation:/scale: => [...]},
48
+ # partiel (seuls les noeuds/proprietes reellement animes ici).
49
+ def pose
50
+ return {} unless @current
51
+
52
+ current_pose = @current.local_pose_at(@time)
53
+ return current_pose unless @previous
54
+
55
+ blend_t = @transition_duration.zero? ? 1.0 : (@transition_elapsed / @transition_duration).clamp(0.0, 1.0)
56
+ blend_poses(@previous.local_pose_at(@previous_time), current_pose, blend_t)
57
+ end
58
+
59
+ private
60
+
61
+ def advance_time(time, dt, animation, loop)
62
+ time += dt
63
+ return time % animation.duration if loop && animation.duration.positive?
64
+
65
+ [time, animation.duration].min
66
+ end
67
+
68
+ def blend_poses(from_pose, dest_pose, progress)
69
+ nodes = from_pose.keys | dest_pose.keys
70
+ nodes.to_h { |node| [node, blend_node(from_pose[node], dest_pose[node], progress)] }
71
+ end
72
+
73
+ def blend_node(from, dest, progress)
74
+ from ||= {}
75
+ dest ||= {}
76
+ (from.keys | dest.keys).to_h { |path| [path, blend_property(path, from[path], dest[path], progress)] }
77
+ end
78
+
79
+ # Si une seule des deux animations definit cette propriete pour ce
80
+ # noeud, pas de vraie valeur "avant" a interpoler — on la garde telle
81
+ # quelle plutot que d'inventer une pose de depart arbitraire.
82
+ def blend_property(path, from, dest, progress)
83
+ return dest unless from
84
+ return from unless dest
85
+ return GLTFMath.quat_slerp(from, dest, progress) if path == :rotation
86
+
87
+ GLTFMath.vec3_lerp(from, dest, progress)
88
+ end
89
+ end
90
+ 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,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+
5
+ module Bestguigui
6
+ # Charge un modele .glb (glTF binaire, un seul fichier auto-suffisant —
7
+ # voir GLTFBuffer) : squelette et animations (voir GLTFSkeleton/
8
+ # GLTFAnimation/GLTFAnimator), rendu en shading: :texture (par defaut) ou
9
+ # :gouraud (voir GouraudShading), sans aucun shader — le skinning se fait
10
+ # sur le CPU (voir GLTFPrimitive). Un seul skin/squelette par fichier
11
+ # (cf. GLTFSkeleton).
12
+ #
13
+ # model = Bestguigui::Assets.gltf_model('assets/jill.glb')
14
+ # model.play('Idle')
15
+ # # dans update(dt)/draw de ta Scene :
16
+ # model.update(dt)
17
+ # model.draw
18
+ # model.play('walk', transition: 0.3) # fondu enchaine de 0.3s vers walk
19
+ #
20
+ # rigid: true interdit toute deformation du maillage par mise a
21
+ # l'echelle d'un os (seules rotation et translation l'articulent) — voir
22
+ # GLTFSkeleton.
23
+ class GLTFModel
24
+ def initialize(path, shading: :texture, rigid: false)
25
+ super()
26
+ @shading = shading
27
+ @rigid = rigid
28
+ load_scene(path)
29
+ @animator = GLTFAnimator.new
30
+ reskin
31
+ end
32
+
33
+ def animation_names = @animations.keys
34
+
35
+ def play(name, loop: true, transition: 0.0)
36
+ animation = @animations.fetch(name) { raise ArgumentError, "animation glTF inconnue : #{name.inspect}" }
37
+ @animator.play(animation, loop: loop, transition: transition)
38
+ end
39
+
40
+ def update(dt)
41
+ @animator.update(dt)
42
+ reskin
43
+ end
44
+
45
+ def draw
46
+ @primitives.each(&:draw)
47
+ end
48
+
49
+ private
50
+
51
+ def load_scene(path)
52
+ @buffer = GLTFBuffer.new(path)
53
+ @json = @buffer.json
54
+ @textures = (@json['images'] || []).map { |image| load_image(image) }
55
+ @skeleton = build_skeleton
56
+ @animations = build_animations
57
+ @primitives = build_primitives
58
+ end
59
+
60
+ def reskin
61
+ pose = @animator.pose
62
+ @primitives.each { |primitive| primitive.skin!(@skeleton.joint_matrices(pose, primitive.mesh_node)) }
63
+ end
64
+
65
+ # --- Squelette (un seul skin, cf. GLTFSkeleton) ---
66
+
67
+ def build_skeleton
68
+ skin = @json['skins'][0]
69
+ inverse_bind_matrices = @buffer.read_accessor(skin['inverseBindMatrices'])
70
+ GLTFSkeleton.new(@json['nodes'], skin['joints'], inverse_bind_matrices, rigid: @rigid)
71
+ end
72
+
73
+ # --- Animations (cf. GLTFAnimation) ---
74
+
75
+ def build_animations
76
+ (@json['animations'] || []).to_h do |animation|
77
+ channels = animation['channels'].map { |channel| build_channel(channel, animation['samplers']) }
78
+ [animation['name'], GLTFAnimation.new(animation['name'], channels)]
79
+ end
80
+ end
81
+
82
+ def build_channel(channel, samplers)
83
+ sampler = samplers[channel['sampler']]
84
+ {
85
+ node: channel['target']['node'],
86
+ path: channel['target']['path'].to_sym,
87
+ times: @buffer.read_accessor(sampler['input']),
88
+ values: @buffer.read_accessor(sampler['output']),
89
+ interpolation: sampler['interpolation'] || 'LINEAR'
90
+ }
91
+ end
92
+
93
+ # --- Maillages (cf. GLTFPrimitive) ---
94
+
95
+ def build_primitives
96
+ primitives = []
97
+ @json['nodes'].each_with_index do |node, node_index|
98
+ next unless node['mesh']
99
+
100
+ @json['meshes'][node['mesh']]['primitives'].each do |primitive|
101
+ primitives << build_primitive(primitive, node_index)
102
+ end
103
+ end
104
+ primitives
105
+ end
106
+
107
+ def build_primitive(primitive, node_index)
108
+ attributes = primitive['attributes']
109
+ geometry = {
110
+ positions: @buffer.read_accessor(attributes['POSITION']),
111
+ normals: @buffer.read_accessor(attributes['NORMAL']),
112
+ texcoords: @buffer.read_accessor(attributes['TEXCOORD_0']),
113
+ joints: @buffer.read_accessor(attributes['JOINTS_0']),
114
+ weights: @buffer.read_accessor(attributes['WEIGHTS_0']),
115
+ indices: @buffer.read_accessor(primitive['indices'])
116
+ }
117
+ GLTFPrimitive.new(geometry, primitive_texture(primitive['material']), node_index, shading: @shading)
118
+ end
119
+
120
+ def primitive_texture(material_index)
121
+ return nil unless material_index
122
+
123
+ texture_ref = @json['materials'][material_index].dig('pbrMetallicRoughness', 'baseColorTexture')
124
+ return nil unless texture_ref
125
+
126
+ @textures[@json['textures'][texture_ref['index']]['source']]
127
+ end
128
+
129
+ # --- Texture embarquee : ecrite dans un fichier temporaire le temps
130
+ # du chargement (Gosu::Image.new ne sait lire qu'un chemin de
131
+ # fichier, pas un blob PNG/JPEG deja en memoire). Le fichier est
132
+ # ferme AVANT que Gosu ne le lise puis explicitement supprime juste
133
+ # apres : sous Windows, un fichier encore ouvert en ecriture ne peut
134
+ # pas etre rouvert en lecture par un autre appel (contrairement a
135
+ # POSIX, ou Tempfile.create avec bloc aurait suffi).
136
+ def load_image(image_json)
137
+ return nil unless image_json['bufferView']
138
+
139
+ bytes = @buffer.read_buffer_view(image_json['bufferView'])
140
+ extension = image_json['mimeType'] == 'image/jpeg' ? '.jpg' : '.png'
141
+ write_temp_texture(bytes, extension)
142
+ end
143
+
144
+ def write_temp_texture(bytes, extension)
145
+ file = Tempfile.new(['gltf_texture', extension])
146
+ file.binmode
147
+ file.write(bytes)
148
+ file.close
149
+ GLTexture.new(file.path)
150
+ ensure
151
+ file&.unlink
152
+ end
153
+ end
154
+ 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.0'
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.0
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