bestguigui_lab 0.12.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: 232fd8759bb525cb0e243e8fa4eccfb4fa94dc9b4a0f6fb7ce73a003f52d505d
4
- data.tar.gz: 666da34d4cea8a5f3d2efec9564e6d610854a249ffd9bf484d3b1a41dce7cc3a
3
+ metadata.gz: d384a7e131d476249187d97515f7f62f9c165702063908cfb7852ef261300d6c
4
+ data.tar.gz: 523552046db0a7e19c10556a4782ad37ad8ee272053d037345985717ed698fe1
5
5
  SHA512:
6
- metadata.gz: 8950ebdc674b3141c96dd79872f55af0ac520c1e2bfc13270cffd887b337d7dc36c6890b7a28c86f6136bddd8c28aad565d274ef7830e97e2747e427e22c7752
7
- data.tar.gz: '039120dc1ae9996040c279190835707d89ebdf935b7ffd6efc4cd5323b4ed0a2dd80f2e4b2796f2ee0c6ae4a517ea6ec6a1f349c77a820204e99d0ba1da02d8a'
6
+ metadata.gz: ed74df8e29395e4545125d07f8a53d17a927b59d2fe235f3c8ac75d22dcc0d6e0ff912775b03c2c9f3a0799c369c3d24e0c05c1ed466bbea2842c79fbe42b09c
7
+ data.tar.gz: 1441f99ec534d3cade5502c36c1c1ebcc6a3ddfaeda66ac1affd710405490f7a01f67a986242cf2b1440d6d5c161bd050a81c7993e5b1235644df311738b7238
@@ -10,7 +10,8 @@ module Bestguigui
10
10
  @sounds = Hash.new { |cache, path| cache[path] = Gosu::Sample.new(path) }
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
- @models = Hash.new { |cache, path| cache[path] = ObjModel.new(path) }
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
 
@@ -28,7 +29,8 @@ module Bestguigui
28
29
  @fonts[[path, size]]
29
30
  end
30
31
 
31
- def self.model(name) = @models[resolve(name)]
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,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bestguigui
4
+ # Envoie un buffer de floats vers un VBO OpenGL (GL_ARRAY_BUFFER) et
5
+ # renvoie son nom — utilisé par ObjModel pour charger sa géométrie.
6
+ class GLBuffer
7
+ def self.upload(data)
8
+ name_buffer = "\x00" * 4
9
+ glGenBuffers(1, name_buffer)
10
+ vbo = name_buffer.unpack1('L')
11
+
12
+ glBindBuffer(GL_ARRAY_BUFFER, vbo)
13
+ glBufferData(GL_ARRAY_BUFFER, data.bytesize, data, GL_STATIC_DRAW)
14
+
15
+ vbo
16
+ end
17
+ end
18
+ 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
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bestguigui
4
+ # Calcule une couleur par sommet à partir de sa normale et d'une lumière
5
+ # fixe, quantifiée en peu de nuances par canal — utilisé par ObjModel en
6
+ # mode shading: :gouraud pour simuler, en plus de la texture (elle la
7
+ # module, ne la remplace pas), un rendu façon modèles de perso PSX
8
+ # (FF7 & co) : pas de lumière OpenGL temps réel, un effet "en bandes"
9
+ # plutôt qu'un dégradé lisse.
10
+ class GouraudShading
11
+ LIGHT_DIRECTION = [0.4, 0.8, 0.4].freeze # direction fixe
12
+ AMBIENT = 0.35 # part de lumière reçue même côté ombre
13
+ COLOR_LEVELS = 16 # teintes par canal : peu de nuances, façon banding PSX
14
+
15
+ def initialize(light_direction: LIGHT_DIRECTION, ambient: AMBIENT, color_levels: COLOR_LEVELS)
16
+ @light_direction = normalize(light_direction)
17
+ @ambient = ambient
18
+ @color_levels = color_levels
19
+ end
20
+
21
+ def vertex_color(normal, material_color)
22
+ intensity = @ambient + ((1.0 - @ambient) * [dot(normal, @light_direction), 0.0].max)
23
+ material_color.map { |channel| quantize(channel * intensity) }
24
+ end
25
+
26
+ private
27
+
28
+ def dot(vector_a, vector_b) = vector_a.zip(vector_b).sum { |x, y| x * y }
29
+
30
+ def normalize(vector)
31
+ length = Math.sqrt(vector.sum { |c| c * c })
32
+ vector.map { |c| c / length }
33
+ end
34
+
35
+ # Réduit à COLOR_LEVELS nuances entre 0 et 1 : le dégradé de lumière
36
+ # devient visiblement "en bandes", comme la faible précision couleur
37
+ # de la PSX.
38
+ def quantize(value)
39
+ step = 1.0 / (@color_levels - 1)
40
+ (value.clamp(0.0, 1.0) / step).round * step
41
+ end
42
+ end
43
+ end
@@ -1,20 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bestguigui
4
- # Charge un modèle .obj/.mtl et le dessine via un VBO : géométrie
4
+ # Charge un modèle .obj/.mtl et le dessine via un VBO texturé. shading:
5
+ # :gouraud ajoute, en plus de la texture (ou d'un blanc si absente), une
6
+ # teinte par sommet calculée une fois au chargement à partir de sa
7
+ # normale et d'une lumière fixe (voir GouraudShading) : simule le rendu
8
+ # bas-poly des perso PSX (FF7 & co), sans lumière OpenGL temps réel.
5
9
  class ObjModel
6
- # position (3) + texcoord (2) + normale (3) par sommet.
10
+ # position (3) + texcoord (2) + normale ou teinte Gouraud (3) par sommet.
7
11
  FLOATS_PER_VERTEX = 8
8
12
  STRIDE = FLOATS_PER_VERTEX * 4 # 4 octets par float
9
13
 
10
- def initialize(obj_path)
14
+ def initialize(obj_path, shading: :texture)
11
15
  super()
12
- @vertices = []
13
- @tex_coords = []
14
- @normals = []
15
- @faces = []
16
+ @shading = shading
17
+ # rubocop:disable-next Style/ParallelAssignment -- reste sous la limite de Metrics/MethodLength
18
+ @vertices, @tex_coords, @normals, @faces = [], [], [], []
16
19
  @texture = nil
17
-
20
+ @material_color = [1.0, 1.0, 1.0]
18
21
  parse_obj(obj_path)
19
22
  @texture ||= GLTexture.blank
20
23
  @vertex_count = @faces.size
@@ -75,53 +78,60 @@ module Bestguigui
75
78
  end
76
79
  end
77
80
 
78
- # Un seul matériau par modèle : la dernière texture (map_Kd) trouvée.
81
+ # Un seul matériau : dernière texture (map_Kd) et couleur diffuse (Kd) trouvées, Kd sert de base au Gouraud.
79
82
  def load_material(path)
80
83
  directory = File.dirname(path)
81
84
 
82
85
  File.foreach(path) do |line|
83
86
  tokens = line.split
84
- next if tokens.empty? || tokens.shift != 'map_Kd'
87
+ next if tokens.empty?
85
88
 
86
- @texture = GLTexture.new(File.join(directory, tokens.first))
89
+ keyword = tokens.shift
90
+ case keyword
91
+ when 'Kd' then @material_color = tokens.map(&:to_f)
92
+ when 'map_Kd' then @texture = GLTexture.new(File.join(directory, tokens.first))
93
+ end
87
94
  end
88
95
  end
89
96
 
90
97
  # Buffer plat non indexé : les sommets partagés sont dupliqués, pas
91
98
  # d'index buffer pour rester simple.
92
99
  def build_vertex_buffer
93
- data = @faces.flat_map { |v, vt, vn| vertex_attributes(v, vt, vn) }.pack('F*')
94
-
95
- name_buffer = "\x00" * 4
96
- glGenBuffers(1, name_buffer)
97
- vbo = name_buffer.unpack1('L')
98
-
99
- glBindBuffer(GL_ARRAY_BUFFER, vbo)
100
- glBufferData(GL_ARRAY_BUFFER, data.bytesize, data, GL_STATIC_DRAW)
101
-
102
- vbo
100
+ gouraud = GouraudShading.new if @shading == :gouraud
101
+ data = @faces.flat_map { |v, vt, vn| vertex_attributes(v, vt, vn, gouraud) }.pack('F*')
102
+ GLBuffer.upload(data)
103
103
  end
104
104
 
105
- def vertex_attributes(vertex_index, tex_coord_index, normal_index)
105
+ # extra : la normale (mode :texture, inutilisée) ou une teinte Gouraud qui modulera la texture au dessin.
106
+ def vertex_attributes(vertex_index, tex_coord_index, normal_index, gouraud)
106
107
  position = @vertices[vertex_index]
107
108
  tex_coord = tex_coord_index ? @tex_coords[tex_coord_index] : [0.0, 0.0]
108
109
  normal = normal_index ? @normals[normal_index] : [0.0, 0.0, 1.0]
110
+ extra = gouraud ? gouraud.vertex_color(normal, @material_color) : normal
109
111
 
110
- position + tex_coord + normal
112
+ position + tex_coord + extra
111
113
  end
112
114
 
113
115
  def enable_vertex_arrays
114
116
  glEnableClientState(GL_VERTEX_ARRAY)
115
117
  glEnableClientState(GL_TEXTURE_COORD_ARRAY)
116
- glEnableClientState(GL_NORMAL_ARRAY)
117
-
118
118
  glVertexPointer(3, GL_FLOAT, STRIDE, 0)
119
119
  glTexCoordPointer(2, GL_FLOAT, STRIDE, 3 * 4)
120
+ @shading == :gouraud ? enable_color_array : enable_normal_array
121
+ end
122
+
123
+ def enable_color_array
124
+ glEnableClientState(GL_COLOR_ARRAY)
125
+ glColorPointer(3, GL_FLOAT, STRIDE, 5 * 4)
126
+ end
127
+
128
+ def enable_normal_array
129
+ glEnableClientState(GL_NORMAL_ARRAY)
120
130
  glNormalPointer(GL_FLOAT, STRIDE, 5 * 4)
121
131
  end
122
132
 
123
133
  def disable_vertex_arrays
124
- glDisableClientState(GL_NORMAL_ARRAY)
134
+ glDisableClientState(@shading == :gouraud ? GL_COLOR_ARRAY : GL_NORMAL_ARRAY)
125
135
  glDisableClientState(GL_TEXTURE_COORD_ARRAY)
126
136
  glDisableClientState(GL_VERTEX_ARRAY)
127
137
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bestguigui
4
- VERSION = '0.12.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.12.0
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bestguigui
@@ -45,7 +45,16 @@ files:
45
45
  - lib/bestguigui_lab/animation.rb
46
46
  - lib/bestguigui_lab/assets.rb
47
47
  - lib/bestguigui_lab/assets/splash_jingle.wav
48
+ - lib/bestguigui_lab/gl_buffer.rb
48
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
57
+ - lib/bestguigui_lab/gouraud_shading.rb
49
58
  - lib/bestguigui_lab/helpers.rb
50
59
  - lib/bestguigui_lab/image_pixels.rb
51
60
  - lib/bestguigui_lab/input.rb