quake-rb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/bin/quake +143 -0
- data/bin/quake-debug +83 -0
- data/lib/quake/bsp/face_vertices.rb +63 -0
- data/lib/quake/bsp/reader.rb +264 -0
- data/lib/quake/bsp/types.rb +30 -0
- data/lib/quake/bsp/vis.rb +246 -0
- data/lib/quake/camera.rb +99 -0
- data/lib/quake/debug/png_writer.rb +58 -0
- data/lib/quake/debug/screenshot.rb +26 -0
- data/lib/quake/debug/script.rb +179 -0
- data/lib/quake/entity.rb +116 -0
- data/lib/quake/game/brush_entities.rb +361 -0
- data/lib/quake/game/engine.rb +300 -0
- data/lib/quake/game/item_pickups.rb +137 -0
- data/lib/quake/game/player_state.rb +158 -0
- data/lib/quake/math/vec3.rb +35 -0
- data/lib/quake/mdl/reader.rb +176 -0
- data/lib/quake/mdl/types.rb +30 -0
- data/lib/quake/pak/reader.rb +57 -0
- data/lib/quake/pak_downloader.rb +145 -0
- data/lib/quake/palette.rb +32 -0
- data/lib/quake/physics/hull_trace.rb +193 -0
- data/lib/quake/physics/player.rb +357 -0
- data/lib/quake/renderer/gl_alias_model.rb +122 -0
- data/lib/quake/renderer/gl_brush_model.rb +162 -0
- data/lib/quake/renderer/gl_hud.rb +226 -0
- data/lib/quake/renderer/gl_lightmap.rb +261 -0
- data/lib/quake/renderer/gl_particles.rb +173 -0
- data/lib/quake/renderer/gl_sky.rb +166 -0
- data/lib/quake/renderer/gl_texture_manager.rb +54 -0
- data/lib/quake/renderer/gl_textured.rb +224 -0
- data/lib/quake/renderer/gl_viewmodel.rb +109 -0
- data/lib/quake/renderer/gl_water.rb +200 -0
- data/lib/quake/renderer/gl_wireframe.rb +36 -0
- data/lib/quake/sound/events.rb +58 -0
- data/lib/quake/sound/mixer.rb +105 -0
- data/lib/quake/version.rb +5 -0
- data/lib/quake/wad/reader.rb +69 -0
- data/lib/quake/window.rb +74 -0
- data/lib/quake.rb +19 -0
- metadata +140 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opengl"
|
|
4
|
+
|
|
5
|
+
module Quake
|
|
6
|
+
module Renderer
|
|
7
|
+
# Renders Quake sky using the two-layer scrolling technique.
|
|
8
|
+
# Sky textures are 256x128, split into:
|
|
9
|
+
# - Left half (128x128): solid background layer
|
|
10
|
+
# - Right half (128x128): alpha foreground layer (index 0 = transparent)
|
|
11
|
+
class GLSky
|
|
12
|
+
def initialize(level, palette, texture_manager)
|
|
13
|
+
@level = level
|
|
14
|
+
@palette = palette
|
|
15
|
+
@texture_manager = texture_manager
|
|
16
|
+
@sky_surfaces = []
|
|
17
|
+
@solid_tex = nil
|
|
18
|
+
@alpha_tex = nil
|
|
19
|
+
@time = 0.0
|
|
20
|
+
|
|
21
|
+
find_sky_texture
|
|
22
|
+
precompute_sky_surfaces
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def update(dt)
|
|
26
|
+
@time += dt
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def render(camera, visible_face_set: nil)
|
|
30
|
+
return if @sky_surfaces.empty? || @solid_tex.nil?
|
|
31
|
+
|
|
32
|
+
# Filter to PVS-visible sky surfaces only. Without this, distant
|
|
33
|
+
# sky polys (e.g. the level's outdoor exit area) draw even when the
|
|
34
|
+
# camera is enclosed in a cave - and underwater they alpha-blend
|
|
35
|
+
# through translucent water as bright cloud shapes.
|
|
36
|
+
visible_surfaces = if visible_face_set
|
|
37
|
+
@sky_surfaces.select { |s| visible_face_set.include?(s[:face_index]) }
|
|
38
|
+
else
|
|
39
|
+
@sky_surfaces
|
|
40
|
+
end
|
|
41
|
+
return if visible_surfaces.empty?
|
|
42
|
+
|
|
43
|
+
# Render solid background layer
|
|
44
|
+
GL.BindTexture(GL::TEXTURE_2D, @solid_tex)
|
|
45
|
+
speed_scale = @time * 8.0
|
|
46
|
+
speed_scale -= speed_scale.floor & ~127
|
|
47
|
+
|
|
48
|
+
visible_surfaces.each do |surf|
|
|
49
|
+
GL.Begin(GL::TRIANGLE_FAN)
|
|
50
|
+
surf[:vertices].each do |v|
|
|
51
|
+
s, t = sky_texcoord(v, camera.position, speed_scale)
|
|
52
|
+
GL.TexCoord2f(s, t)
|
|
53
|
+
GL.Vertex3f(v.x, v.y, v.z)
|
|
54
|
+
end
|
|
55
|
+
GL.End
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Render alpha foreground layer with blending
|
|
59
|
+
GL.Enable(GL::BLEND)
|
|
60
|
+
GL.BlendFunc(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA)
|
|
61
|
+
GL.BindTexture(GL::TEXTURE_2D, @alpha_tex)
|
|
62
|
+
speed_scale2 = @time * 16.0
|
|
63
|
+
speed_scale2 -= speed_scale2.floor & ~127
|
|
64
|
+
|
|
65
|
+
visible_surfaces.each do |surf|
|
|
66
|
+
GL.Begin(GL::TRIANGLE_FAN)
|
|
67
|
+
surf[:vertices].each do |v|
|
|
68
|
+
s, t = sky_texcoord(v, camera.position, speed_scale2)
|
|
69
|
+
GL.TexCoord2f(s, t)
|
|
70
|
+
GL.Vertex3f(v.x, v.y, v.z)
|
|
71
|
+
end
|
|
72
|
+
GL.End
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
GL.Disable(GL::BLEND)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def sky_texcoord(vertex, origin, speed_scale)
|
|
81
|
+
# Direction from camera to vertex
|
|
82
|
+
dx = vertex.x - origin.x
|
|
83
|
+
dy = vertex.y - origin.y
|
|
84
|
+
dz = (vertex.z - origin.z) * 3.0 # flatten sphere
|
|
85
|
+
|
|
86
|
+
len = ::Math.sqrt(dx * dx + dy * dy + dz * dz)
|
|
87
|
+
len = 6.0 * 63.0 / len # 378 / len
|
|
88
|
+
|
|
89
|
+
s = (speed_scale + dx * len) / 128.0
|
|
90
|
+
t = (speed_scale + dy * len) / 128.0
|
|
91
|
+
[s, t]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def find_sky_texture
|
|
95
|
+
@level.textures.each_with_index do |tex, idx|
|
|
96
|
+
next if tex.nil?
|
|
97
|
+
next unless tex.name.start_with?("sky")
|
|
98
|
+
|
|
99
|
+
# Split 256x128 texture into two 128x128 halves
|
|
100
|
+
upload_sky_halves(tex)
|
|
101
|
+
break
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def upload_sky_halves(miptex)
|
|
106
|
+
w = miptex.width # 256
|
|
107
|
+
h = miptex.height # 128
|
|
108
|
+
half_w = w / 2 # 128
|
|
109
|
+
|
|
110
|
+
# Extract left half (solid background)
|
|
111
|
+
solid_pixels = String.new(capacity: half_w * h)
|
|
112
|
+
h.times do |row|
|
|
113
|
+
src_offset = row * w
|
|
114
|
+
solid_pixels << miptex.pixels[src_offset, half_w]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Extract right half (alpha foreground)
|
|
118
|
+
alpha_pixels = String.new(capacity: half_w * h)
|
|
119
|
+
h.times do |row|
|
|
120
|
+
src_offset = row * w + half_w
|
|
121
|
+
alpha_pixels << miptex.pixels[src_offset, half_w]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
@solid_tex = upload_texture(solid_pixels, half_w, h, transparent: false)
|
|
125
|
+
@alpha_tex = upload_texture(alpha_pixels, half_w, h, transparent: true)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def upload_texture(pixels, width, height, transparent:)
|
|
129
|
+
rgba = String.new(capacity: width * height * 4)
|
|
130
|
+
pixels.each_byte do |idx|
|
|
131
|
+
r, g, b = @palette.rgb(idx)
|
|
132
|
+
a = (transparent && idx == 0) ? 0 : 255
|
|
133
|
+
rgba << r << g << b << a
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
buf = "\0" * 4
|
|
137
|
+
GL.GenTextures(1, buf)
|
|
138
|
+
tex_id = buf.unpack1("V")
|
|
139
|
+
|
|
140
|
+
GL.BindTexture(GL::TEXTURE_2D, tex_id)
|
|
141
|
+
GL.TexImage2D(GL::TEXTURE_2D, 0, GL::RGBA, width, height, 0,
|
|
142
|
+
GL::RGBA, GL::UNSIGNED_BYTE, rgba)
|
|
143
|
+
GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_MIN_FILTER, GL::LINEAR)
|
|
144
|
+
GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_MAG_FILTER, GL::LINEAR)
|
|
145
|
+
GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_S, GL::REPEAT)
|
|
146
|
+
GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_T, GL::REPEAT)
|
|
147
|
+
tex_id
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def precompute_sky_surfaces
|
|
151
|
+
@level.faces.each_with_index do |face, face_index|
|
|
152
|
+
texinfo = @level.texinfo[face.texinfo_index]
|
|
153
|
+
next if texinfo.nil?
|
|
154
|
+
|
|
155
|
+
tex = @level.textures[texinfo.miptex_index]
|
|
156
|
+
next if tex.nil?
|
|
157
|
+
next unless tex.name.start_with?("sky")
|
|
158
|
+
|
|
159
|
+
verts = Bsp::FaceVertices.extract(@level, face)
|
|
160
|
+
@sky_surfaces << { vertices: verts, face_index: face_index }
|
|
161
|
+
end
|
|
162
|
+
puts "Found #{@sky_surfaces.size} sky surfaces"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opengl"
|
|
4
|
+
|
|
5
|
+
module Quake
|
|
6
|
+
module Renderer
|
|
7
|
+
class GLTextureManager
|
|
8
|
+
TEX_SPECIAL = 1
|
|
9
|
+
|
|
10
|
+
attr_reader :gl_textures
|
|
11
|
+
|
|
12
|
+
def initialize(level, palette)
|
|
13
|
+
@level = level
|
|
14
|
+
@palette = palette
|
|
15
|
+
@gl_textures = {} # miptex_index -> GL texture id
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def upload_all
|
|
19
|
+
@level.textures.each_with_index do |miptex, index|
|
|
20
|
+
next if miptex.nil? || miptex.pixels.nil?
|
|
21
|
+
@gl_textures[index] = upload_texture(miptex)
|
|
22
|
+
end
|
|
23
|
+
puts "Uploaded #{@gl_textures.size} textures to GL"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def bind(miptex_index)
|
|
27
|
+
tex_id = @gl_textures[miptex_index]
|
|
28
|
+
GL.BindTexture(GL::TEXTURE_2D, tex_id || 0)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def upload_texture(miptex)
|
|
34
|
+
rgba = @palette.indexed_to_rgba(miptex.pixels)
|
|
35
|
+
|
|
36
|
+
buf = "\0" * 4
|
|
37
|
+
GL.GenTextures(1, buf)
|
|
38
|
+
tex_id = buf.unpack1("V")
|
|
39
|
+
|
|
40
|
+
GL.BindTexture(GL::TEXTURE_2D, tex_id)
|
|
41
|
+
GL.TexImage2D(GL::TEXTURE_2D, 0, GL::RGBA,
|
|
42
|
+
miptex.width, miptex.height, 0,
|
|
43
|
+
GL::RGBA, GL::UNSIGNED_BYTE, rgba)
|
|
44
|
+
GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_S, GL::REPEAT)
|
|
45
|
+
GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_T, GL::REPEAT)
|
|
46
|
+
GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_MIN_FILTER, GL::LINEAR_MIPMAP_LINEAR)
|
|
47
|
+
GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_MAG_FILTER, GL::LINEAR)
|
|
48
|
+
GL.GenerateMipmap(GL::TEXTURE_2D)
|
|
49
|
+
|
|
50
|
+
tex_id
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opengl"
|
|
4
|
+
|
|
5
|
+
module Quake
|
|
6
|
+
module Renderer
|
|
7
|
+
class GLTextured
|
|
8
|
+
TEX_SPECIAL = 1
|
|
9
|
+
|
|
10
|
+
def initialize(level, texture_manager, lightmap: nil, sky: nil, water: nil)
|
|
11
|
+
@level = level
|
|
12
|
+
@texture_manager = texture_manager
|
|
13
|
+
@lightmap = lightmap
|
|
14
|
+
@sky = sky
|
|
15
|
+
@water = water
|
|
16
|
+
@last_leaf = -1
|
|
17
|
+
@visible_surface_cache = nil
|
|
18
|
+
|
|
19
|
+
# Precompute ALL surfaces grouped by texture (for non-VIS fallback
|
|
20
|
+
# and as source data for VIS filtering)
|
|
21
|
+
@all_surfaces_by_texture = precompute_surfaces
|
|
22
|
+
@face_to_surface = build_face_index
|
|
23
|
+
|
|
24
|
+
puts "Precomputed #{@all_surfaces_by_texture.values.sum(&:size)} surfaces across #{@all_surfaces_by_texture.size} textures"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def render(camera, aspect)
|
|
28
|
+
GL.Clear(GL::COLOR_BUFFER_BIT | GL::DEPTH_BUFFER_BIT)
|
|
29
|
+
|
|
30
|
+
camera.apply_projection_gl(aspect)
|
|
31
|
+
camera.apply_gl
|
|
32
|
+
|
|
33
|
+
# Determine visible surfaces via PVS
|
|
34
|
+
surfaces_by_texture = visible_surfaces(camera)
|
|
35
|
+
|
|
36
|
+
# Render opaque world geometry
|
|
37
|
+
GL.Enable(GL::TEXTURE_2D)
|
|
38
|
+
|
|
39
|
+
if @lightmap
|
|
40
|
+
render_with_lightmaps(surfaces_by_texture)
|
|
41
|
+
else
|
|
42
|
+
render_without_lightmaps(surfaces_by_texture)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Sky drawn before translucent water so translucent water can
|
|
46
|
+
# alpha-blend over sky-tinted background. PVS-cull keeps distant
|
|
47
|
+
# outdoor sky polys out, and TEXTURE_2D must stay enabled or sky
|
|
48
|
+
# polys render as untextured white shapes.
|
|
49
|
+
@sky&.render(camera, visible_face_set: @last_visible_face_set)
|
|
50
|
+
|
|
51
|
+
# Translucent water last so it alpha-blends over everything else.
|
|
52
|
+
@water&.render(alpha: 0.5)
|
|
53
|
+
|
|
54
|
+
GL.Disable(GL::TEXTURE_2D)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def update(dt)
|
|
58
|
+
@sky&.update(dt)
|
|
59
|
+
@water&.update(dt)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def visible_surfaces(camera)
|
|
65
|
+
leaf_index = Bsp::Vis.point_in_leaf(@level, camera.position)
|
|
66
|
+
leaf = @level.leafs[leaf_index]
|
|
67
|
+
# FatPVS depends on point position, so recompute every frame when
|
|
68
|
+
# the camera leaf touches a liquid surface. Otherwise the cheap
|
|
69
|
+
# leaf-keyed cache is fine.
|
|
70
|
+
near_liquid = Bsp::Vis.near_liquid_portal?(@level, leaf)
|
|
71
|
+
|
|
72
|
+
if near_liquid
|
|
73
|
+
compute_visible_surfaces(leaf_index, point: camera.position)
|
|
74
|
+
else
|
|
75
|
+
if leaf_index != @last_leaf
|
|
76
|
+
@last_leaf = leaf_index
|
|
77
|
+
@visible_surface_cache = compute_visible_surfaces(leaf_index)
|
|
78
|
+
end
|
|
79
|
+
@visible_surface_cache || @all_surfaces_by_texture
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def compute_visible_surfaces(leaf_index, point: nil)
|
|
84
|
+
visible_face_set = Bsp::Vis.visible_faces(@level, leaf_index, point: point)
|
|
85
|
+
# Stash so the sky pass can cull against the same set.
|
|
86
|
+
@last_visible_face_set = visible_face_set
|
|
87
|
+
|
|
88
|
+
filtered = Hash.new { |h, k| h[k] = [] }
|
|
89
|
+
|
|
90
|
+
visible_face_set.each do |face_idx|
|
|
91
|
+
entry = @face_to_surface[face_idx]
|
|
92
|
+
next unless entry
|
|
93
|
+
|
|
94
|
+
miptex_index, surf_data = entry
|
|
95
|
+
filtered[miptex_index] << surf_data
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
filtered
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def render_without_lightmaps(surfaces_by_texture)
|
|
102
|
+
GL.Color3f(1.0, 1.0, 1.0)
|
|
103
|
+
|
|
104
|
+
surfaces_by_texture.each do |miptex_index, surfaces|
|
|
105
|
+
@texture_manager.bind(miptex_index)
|
|
106
|
+
|
|
107
|
+
surfaces.each do |surf|
|
|
108
|
+
GL.Begin(GL::TRIANGLE_FAN)
|
|
109
|
+
surf[:vertices].each_with_index do |v, i|
|
|
110
|
+
u, t = surf[:texcoords][i]
|
|
111
|
+
GL.TexCoord2f(u, t)
|
|
112
|
+
GL.Vertex3f(v.x, v.y, v.z)
|
|
113
|
+
end
|
|
114
|
+
GL.End
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def render_with_lightmaps(surfaces_by_texture)
|
|
120
|
+
# Two-pass approach: diffuse modulated by lightmap
|
|
121
|
+
# Pass 1: render diffuse texture
|
|
122
|
+
GL.TexEnvi(GL::TEXTURE_ENV, GL::TEXTURE_ENV_MODE, GL::REPLACE)
|
|
123
|
+
GL.Color3f(1.0, 1.0, 1.0)
|
|
124
|
+
|
|
125
|
+
surfaces_by_texture.each do |miptex_index, surfaces|
|
|
126
|
+
@texture_manager.bind(miptex_index)
|
|
127
|
+
|
|
128
|
+
surfaces.each do |surf|
|
|
129
|
+
GL.Begin(GL::TRIANGLE_FAN)
|
|
130
|
+
surf[:vertices].each_with_index do |v, i|
|
|
131
|
+
u, t = surf[:texcoords][i]
|
|
132
|
+
GL.TexCoord2f(u, t)
|
|
133
|
+
GL.Vertex3f(v.x, v.y, v.z)
|
|
134
|
+
end
|
|
135
|
+
GL.End
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Pass 2: multiply lightmap on top
|
|
140
|
+
GL.Enable(GL::BLEND)
|
|
141
|
+
GL.BlendFunc(GL::ZERO, GL::SRC_COLOR) # dst = dst * src (multiply)
|
|
142
|
+
GL.DepthFunc(GL::EQUAL) # only write where geometry already drawn
|
|
143
|
+
GL.TexEnvi(GL::TEXTURE_ENV, GL::TEXTURE_ENV_MODE, GL::REPLACE)
|
|
144
|
+
|
|
145
|
+
last_lm_tex = -1
|
|
146
|
+
surfaces_by_texture.each do |_miptex_index, surfaces|
|
|
147
|
+
surfaces.each do |surf|
|
|
148
|
+
face_idx = surf[:face_index]
|
|
149
|
+
next unless face_idx && @lightmap.face_lightmaps[face_idx]
|
|
150
|
+
|
|
151
|
+
lm_info = @lightmap.face_lightmaps[face_idx]
|
|
152
|
+
if lm_info.gl_texture != last_lm_tex
|
|
153
|
+
GL.BindTexture(GL::TEXTURE_2D, lm_info.gl_texture)
|
|
154
|
+
last_lm_tex = lm_info.gl_texture
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
texinfo = @level.texinfo[surf[:texinfo_index]]
|
|
158
|
+
GL.Begin(GL::TRIANGLE_FAN)
|
|
159
|
+
surf[:vertices].each_with_index do |v, i|
|
|
160
|
+
ls, lt = @lightmap.lightmap_texcoords(face_idx, v, texinfo)
|
|
161
|
+
GL.TexCoord2f(ls, lt)
|
|
162
|
+
GL.Vertex3f(v.x, v.y, v.z)
|
|
163
|
+
end
|
|
164
|
+
GL.End
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
GL.DepthFunc(GL::LESS)
|
|
169
|
+
GL.Disable(GL::BLEND)
|
|
170
|
+
GL.TexEnvi(GL::TEXTURE_ENV, GL::TEXTURE_ENV_MODE, GL::MODULATE)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def precompute_surfaces
|
|
174
|
+
grouped = Hash.new { |h, k| h[k] = [] }
|
|
175
|
+
|
|
176
|
+
# Only render faces belonging to model 0 (the worldmodel).
|
|
177
|
+
# Sub-model faces (doors, buttons, lifts) are rendered separately
|
|
178
|
+
# by GLBrushModel with entity transforms applied.
|
|
179
|
+
world = @level.models[0]
|
|
180
|
+
world_first = world.first_face
|
|
181
|
+
world_last = world_first + world.num_faces - 1
|
|
182
|
+
|
|
183
|
+
@level.faces.each_with_index do |face, face_index|
|
|
184
|
+
next if face_index < world_first || face_index > world_last
|
|
185
|
+
|
|
186
|
+
texinfo = @level.texinfo[face.texinfo_index]
|
|
187
|
+
next if texinfo.nil?
|
|
188
|
+
next if texinfo.flags & TEX_SPECIAL != 0
|
|
189
|
+
|
|
190
|
+
miptex_index = texinfo.miptex_index
|
|
191
|
+
tex = @level.textures[miptex_index]
|
|
192
|
+
next if tex.nil?
|
|
193
|
+
# Skip sky and water (handled by dedicated renderers)
|
|
194
|
+
next if tex.name.start_with?("sky")
|
|
195
|
+
next if tex.name.start_with?("*")
|
|
196
|
+
# Trigger volumes are invisible collision brushes
|
|
197
|
+
next if tex.name == "trigger"
|
|
198
|
+
next if tex.name == "clip"
|
|
199
|
+
|
|
200
|
+
surf = Bsp::FaceVertices.extract_surface(@level, face)
|
|
201
|
+
surf_data = {
|
|
202
|
+
vertices: surf.vertices,
|
|
203
|
+
texcoords: surf.texcoords,
|
|
204
|
+
texinfo_index: surf.texinfo_index,
|
|
205
|
+
face_index: face_index
|
|
206
|
+
}
|
|
207
|
+
grouped[miptex_index] << surf_data
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
grouped
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def build_face_index
|
|
214
|
+
index = {}
|
|
215
|
+
@all_surfaces_by_texture.each do |miptex_index, surfaces|
|
|
216
|
+
surfaces.each do |surf_data|
|
|
217
|
+
index[surf_data[:face_index]] = [miptex_index, surf_data]
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
index
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opengl"
|
|
4
|
+
|
|
5
|
+
module Quake
|
|
6
|
+
module Renderer
|
|
7
|
+
# Renders the first-person weapon view model.
|
|
8
|
+
# Uses the same projection as the world camera. Depth range is compressed
|
|
9
|
+
# to [0, 0.3] so the weapon always draws in front of world geometry
|
|
10
|
+
# without z-fighting (matches GLQuake's R_DrawViewModel).
|
|
11
|
+
class GLViewmodel
|
|
12
|
+
# Quake bob cvars (from view.c)
|
|
13
|
+
CL_BOB = 0.02 # cl_bob: amplitude multiplier
|
|
14
|
+
CL_BOBCYCLE = 0.6 # cl_bobcycle: seconds per full bob cycle
|
|
15
|
+
CL_BOBUP = 0.5 # cl_bobup: fraction of cycle spent going up
|
|
16
|
+
|
|
17
|
+
# Current bob value (applied to camera Z by the game loop)
|
|
18
|
+
attr_reader :bob
|
|
19
|
+
|
|
20
|
+
def initialize(pak, palette)
|
|
21
|
+
@pak = pak
|
|
22
|
+
@palette = palette
|
|
23
|
+
@weapon_renderers = {} # model_path -> GLAliasModel
|
|
24
|
+
@time = 0.0
|
|
25
|
+
@bob_speed = 0.0
|
|
26
|
+
@bob = 0.0
|
|
27
|
+
@current_model_path = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def set_weapon(model_path)
|
|
31
|
+
return if model_path == @current_model_path
|
|
32
|
+
@current_model_path = model_path
|
|
33
|
+
load_weapon(model_path) unless @weapon_renderers.key?(model_path)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def update(dt, speed)
|
|
37
|
+
@time += dt
|
|
38
|
+
@bob_speed = speed
|
|
39
|
+
@bob = calc_bob
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def render(camera, aspect)
|
|
43
|
+
return unless @current_model_path
|
|
44
|
+
|
|
45
|
+
gl_model = @weapon_renderers[@current_model_path]
|
|
46
|
+
return unless gl_model
|
|
47
|
+
|
|
48
|
+
# Compress depth range so weapon always appears in front of world
|
|
49
|
+
GL.DepthRange(0.0, 0.3)
|
|
50
|
+
GL.DepthFunc(GL::LEQUAL)
|
|
51
|
+
|
|
52
|
+
# Weapon origin = camera position + forward*bob*0.4
|
|
53
|
+
# Camera already has the vertical bob applied (in the game loop),
|
|
54
|
+
# so only the forward push is needed here.
|
|
55
|
+
pos = camera.position + camera.forward * (@bob * 0.4)
|
|
56
|
+
|
|
57
|
+
GL.Enable(GL::TEXTURE_2D)
|
|
58
|
+
|
|
59
|
+
gl_model.render(
|
|
60
|
+
frame_index: 0,
|
|
61
|
+
lerp: 0.0,
|
|
62
|
+
position: pos,
|
|
63
|
+
yaw: camera.yaw,
|
|
64
|
+
pitch: camera.pitch
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
GL.DepthRange(0.0, 1.0)
|
|
68
|
+
GL.DepthFunc(GL::LESS)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Exact V_CalcBob from Quake's view.c
|
|
74
|
+
def calc_bob
|
|
75
|
+
speed = @bob_speed
|
|
76
|
+
return 0.0 if speed < 1.0
|
|
77
|
+
|
|
78
|
+
# Asymmetric cycle: spend cl_bobup fraction going up, rest going down
|
|
79
|
+
cycle = @time - (@time / CL_BOBCYCLE).floor * CL_BOBCYCLE
|
|
80
|
+
cycle /= CL_BOBCYCLE
|
|
81
|
+
|
|
82
|
+
if cycle < CL_BOBUP
|
|
83
|
+
cycle = ::Math::PI * cycle / CL_BOBUP
|
|
84
|
+
else
|
|
85
|
+
cycle = ::Math::PI + ::Math::PI * (cycle - CL_BOBUP) / (1.0 - CL_BOBUP)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# 30% constant offset + 70% oscillation, proportional to speed
|
|
89
|
+
bob = speed * CL_BOB
|
|
90
|
+
bob = bob * 0.3 + bob * 0.7 * ::Math.sin(cycle)
|
|
91
|
+
bob.clamp(-7.0, 4.0)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def load_weapon(model_path)
|
|
95
|
+
begin
|
|
96
|
+
data = @pak.read(model_path)
|
|
97
|
+
return unless data
|
|
98
|
+
|
|
99
|
+
mdl = Mdl::Reader.new(data).parse
|
|
100
|
+
@weapon_renderers[model_path] = GLAliasModel.new(mdl, @palette)
|
|
101
|
+
puts " Loaded weapon model: #{model_path}"
|
|
102
|
+
rescue => e
|
|
103
|
+
puts " Failed to load weapon #{model_path}: #{e.message}"
|
|
104
|
+
@weapon_renderers[model_path] = nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|