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 +4 -4
- data/lib/bestguigui_lab/assets.rb +2 -0
- data/lib/bestguigui_lab/gltf_animation.rb +53 -0
- data/lib/bestguigui_lab/gltf_animator.rb +90 -0
- data/lib/bestguigui_lab/gltf_buffer.rb +72 -0
- data/lib/bestguigui_lab/gltf_math.rb +148 -0
- data/lib/bestguigui_lab/gltf_model.rb +154 -0
- data/lib/bestguigui_lab/gltf_primitive.rb +132 -0
- data/lib/bestguigui_lab/gltf_skeleton.rb +73 -0
- data/lib/bestguigui_lab/version.rb +1 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d384a7e131d476249187d97515f7f62f9c165702063908cfb7852ef261300d6c
|
|
4
|
+
data.tar.gz: 523552046db0a7e19c10556a4782ad37ad8ee272053d037345985717ed698fe1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
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.
|
|
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
|