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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/bin/quake +143 -0
  3. data/bin/quake-debug +83 -0
  4. data/lib/quake/bsp/face_vertices.rb +63 -0
  5. data/lib/quake/bsp/reader.rb +264 -0
  6. data/lib/quake/bsp/types.rb +30 -0
  7. data/lib/quake/bsp/vis.rb +246 -0
  8. data/lib/quake/camera.rb +99 -0
  9. data/lib/quake/debug/png_writer.rb +58 -0
  10. data/lib/quake/debug/screenshot.rb +26 -0
  11. data/lib/quake/debug/script.rb +179 -0
  12. data/lib/quake/entity.rb +116 -0
  13. data/lib/quake/game/brush_entities.rb +361 -0
  14. data/lib/quake/game/engine.rb +300 -0
  15. data/lib/quake/game/item_pickups.rb +137 -0
  16. data/lib/quake/game/player_state.rb +158 -0
  17. data/lib/quake/math/vec3.rb +35 -0
  18. data/lib/quake/mdl/reader.rb +176 -0
  19. data/lib/quake/mdl/types.rb +30 -0
  20. data/lib/quake/pak/reader.rb +57 -0
  21. data/lib/quake/pak_downloader.rb +145 -0
  22. data/lib/quake/palette.rb +32 -0
  23. data/lib/quake/physics/hull_trace.rb +193 -0
  24. data/lib/quake/physics/player.rb +357 -0
  25. data/lib/quake/renderer/gl_alias_model.rb +122 -0
  26. data/lib/quake/renderer/gl_brush_model.rb +162 -0
  27. data/lib/quake/renderer/gl_hud.rb +226 -0
  28. data/lib/quake/renderer/gl_lightmap.rb +261 -0
  29. data/lib/quake/renderer/gl_particles.rb +173 -0
  30. data/lib/quake/renderer/gl_sky.rb +166 -0
  31. data/lib/quake/renderer/gl_texture_manager.rb +54 -0
  32. data/lib/quake/renderer/gl_textured.rb +224 -0
  33. data/lib/quake/renderer/gl_viewmodel.rb +109 -0
  34. data/lib/quake/renderer/gl_water.rb +200 -0
  35. data/lib/quake/renderer/gl_wireframe.rb +36 -0
  36. data/lib/quake/sound/events.rb +58 -0
  37. data/lib/quake/sound/mixer.rb +105 -0
  38. data/lib/quake/version.rb +5 -0
  39. data/lib/quake/wad/reader.rb +69 -0
  40. data/lib/quake/window.rb +74 -0
  41. data/lib/quake.rb +19 -0
  42. metadata +140 -0
@@ -0,0 +1,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quake
4
+ module Physics
5
+ # Full Quake player physics: gravity, friction, acceleration, jumping,
6
+ # ground detection, stair stepping, and swimming.
7
+ class Player
8
+ attr_accessor :position, :velocity, :on_ground, :water_level, :noclip
9
+ attr_reader :yaw, :pitch
10
+
11
+ # Quake constants
12
+ GRAVITY = 800.0 # units/sec^2
13
+ FRICTION = 4.0
14
+ STOP_SPEED = 100.0
15
+ MAX_SPEED = 320.0
16
+ ACCELERATE = 10.0
17
+ AIR_ACCELERATE = 0.7
18
+ JUMP_SPEED = 270.0
19
+ STEP_SIZE = 18.0
20
+ WATER_FRICTION = 1.0
21
+ WATER_ACCELERATE = 10.0
22
+
23
+ # Camera
24
+ SENSITIVITY = 0.15
25
+ MAX_MOUSE_DELTA = 50
26
+ VIEW_HEIGHT = 22.0 # eye offset from origin (origin is at player feet center)
27
+
28
+ # Ground: surface normal Z must be > 0.7 (roughly < 45 degrees from horizontal)
29
+ MIN_GROUND_NORMAL_Z = 0.7
30
+
31
+ def initialize(position:, yaw: 0.0)
32
+ @position = position
33
+ @velocity = Math::Vec3::ORIGIN
34
+ @yaw = yaw
35
+ @pitch = 0.0
36
+ @on_ground = false
37
+ @water_level = 0 # 0=dry, 1=feet, 2=waist, 3=head
38
+ @jump_held = false
39
+ @noclip = false
40
+ @ignore_mouse = 2
41
+ end
42
+
43
+ # Camera eye position (origin + view height)
44
+ def eye_position
45
+ Math::Vec3.new(@position.x, @position.y, @position.z + VIEW_HEIGHT)
46
+ end
47
+
48
+ def rotate(dx, dy)
49
+ if @ignore_mouse > 0
50
+ @ignore_mouse -= 1
51
+ return
52
+ end
53
+ dx = dx.clamp(-MAX_MOUSE_DELTA, MAX_MOUSE_DELTA)
54
+ dy = dy.clamp(-MAX_MOUSE_DELTA, MAX_MOUSE_DELTA)
55
+ @yaw -= dx * SENSITIVITY
56
+ @pitch += dy * SENSITIVITY
57
+ @pitch = @pitch.clamp(-89.0, 89.0)
58
+ end
59
+
60
+ def update(dt, level, keys, brush_entities: nil)
61
+ if @noclip
62
+ noclip_move(dt, keys)
63
+ return
64
+ end
65
+
66
+ @brush_entities = brush_entities
67
+ categorize_position(level)
68
+
69
+ # Calculate wish direction from input
70
+ wish_dir, wish_speed = compute_wish_velocity(keys)
71
+
72
+ if @water_level >= 2
73
+ water_move(dt, wish_dir, wish_speed, keys, level)
74
+ else
75
+ # Apply gravity if not on ground and not in water
76
+ unless @on_ground
77
+ @velocity = Math::Vec3.new(@velocity.x, @velocity.y,
78
+ @velocity.z - GRAVITY * dt)
79
+ end
80
+
81
+ # Handle jumping (SET vz, don't add - matches Quake's PM_AirMove)
82
+ if @on_ground && keys[SDL::SCANCODE_SPACE] && !@jump_held
83
+ @velocity = Math::Vec3.new(@velocity.x, @velocity.y, JUMP_SPEED)
84
+ @on_ground = false
85
+ @jump_held = true
86
+ end
87
+ @jump_held = false unless keys[SDL::SCANCODE_SPACE]
88
+
89
+ if @on_ground
90
+ apply_friction(dt, level)
91
+ accelerate(wish_dir, wish_speed, ACCELERATE, dt)
92
+ else
93
+ air_accelerate(wish_dir, wish_speed, dt)
94
+ end
95
+ end
96
+
97
+ # Move with collision
98
+ walk_move(dt, level)
99
+ end
100
+
101
+ # Forward direction (horizontal only, for movement)
102
+ def forward_flat
103
+ ry = deg2rad(@yaw)
104
+ Math::Vec3.new(::Math.cos(ry), ::Math.sin(ry), 0.0)
105
+ end
106
+
107
+ def right_flat
108
+ ry = deg2rad(@yaw - 90.0)
109
+ Math::Vec3.new(::Math.cos(ry), ::Math.sin(ry), 0.0)
110
+ end
111
+
112
+ # Full forward including pitch (for camera)
113
+ def forward
114
+ ry = deg2rad(@yaw)
115
+ rp = deg2rad(@pitch)
116
+ cp = ::Math.cos(rp)
117
+ Math::Vec3.new(::Math.cos(ry) * cp, ::Math.sin(ry) * cp, -::Math.sin(rp))
118
+ end
119
+
120
+ def right
121
+ ry = deg2rad(@yaw - 90.0)
122
+ Math::Vec3.new(::Math.cos(ry), ::Math.sin(ry), 0.0)
123
+ end
124
+
125
+ private
126
+
127
+ def deg2rad(deg)
128
+ deg * ::Math::PI / 180.0
129
+ end
130
+
131
+ def compute_wish_velocity(keys)
132
+ wish = Math::Vec3::ORIGIN
133
+ wish = wish + forward_flat if keys[SDL::SCANCODE_W]
134
+ wish = wish - forward_flat if keys[SDL::SCANCODE_S]
135
+ wish = wish + right_flat if keys[SDL::SCANCODE_D]
136
+ wish = wish - right_flat if keys[SDL::SCANCODE_A]
137
+
138
+ len = wish.length
139
+ return [Math::Vec3::ORIGIN, 0.0] if len < 0.001
140
+
141
+ [wish.normalize, MAX_SPEED]
142
+ end
143
+
144
+ def apply_friction(dt, level)
145
+ speed = ::Math.sqrt(@velocity.x**2 + @velocity.y**2)
146
+ return if speed < 1.0
147
+
148
+ control = [speed, STOP_SPEED].max
149
+ drop = control * FRICTION * dt
150
+
151
+ new_speed = [speed - drop, 0.0].max / speed
152
+ @velocity = Math::Vec3.new(
153
+ @velocity.x * new_speed,
154
+ @velocity.y * new_speed,
155
+ @velocity.z
156
+ )
157
+ end
158
+
159
+ def accelerate(wish_dir, wish_speed, accel, dt)
160
+ current_speed = @velocity.dot(wish_dir)
161
+ add_speed = wish_speed - current_speed
162
+ return if add_speed <= 0
163
+
164
+ accel_speed = [accel * dt * wish_speed, add_speed].min
165
+ @velocity = @velocity + wish_dir * accel_speed
166
+ end
167
+
168
+ def air_accelerate(wish_dir, wish_speed, dt)
169
+ current_speed = @velocity.dot(wish_dir)
170
+ wish_spd = [wish_speed, 30.0].min # air strafe cap
171
+ add_speed = wish_spd - current_speed
172
+ return if add_speed <= 0
173
+
174
+ accel_speed = [AIR_ACCELERATE * wish_speed * dt, add_speed].min
175
+ @velocity = @velocity + wish_dir * accel_speed
176
+ end
177
+
178
+ def water_move(dt, wish_dir, wish_speed, keys, level)
179
+ # Water friction
180
+ speed = @velocity.length
181
+ if speed > 0
182
+ new_speed = [speed - dt * speed * WATER_FRICTION * @water_level, 0.0].max
183
+ @velocity = @velocity * (new_speed / speed)
184
+ end
185
+
186
+ # Build wish velocity from horizontal input + vertical keys
187
+ has_horizontal = wish_speed > 0.001
188
+ has_up = keys[SDL::SCANCODE_SPACE]
189
+ has_down = keys[SDL::SCANCODE_C]
190
+
191
+ # Use forward direction including pitch when swimming
192
+ swim_wish = Math::Vec3::ORIGIN
193
+ if keys[SDL::SCANCODE_W]
194
+ swim_wish = swim_wish + forward
195
+ end
196
+ if keys[SDL::SCANCODE_S]
197
+ swim_wish = swim_wish - forward
198
+ end
199
+ if keys[SDL::SCANCODE_D]
200
+ swim_wish = swim_wish + right
201
+ end
202
+ if keys[SDL::SCANCODE_A]
203
+ swim_wish = swim_wish - right
204
+ end
205
+
206
+ # Vertical movement
207
+ if has_up
208
+ swim_wish = Math::Vec3.new(swim_wish.x, swim_wish.y, swim_wish.z + 1.0)
209
+ elsif has_down
210
+ swim_wish = Math::Vec3.new(swim_wish.x, swim_wish.y, swim_wish.z - 1.0)
211
+ end
212
+
213
+ swim_len = swim_wish.length
214
+ if swim_len < 0.001
215
+ # No input at all: drift down slowly
216
+ @velocity = Math::Vec3.new(@velocity.x, @velocity.y, @velocity.z - 60.0 * dt)
217
+ return
218
+ end
219
+
220
+ wish_dir2 = swim_wish.normalize
221
+ wish_spd = MAX_SPEED * 0.7 # water reduces max speed to 70%
222
+ accelerate(wish_dir2, wish_spd, WATER_ACCELERATE, dt)
223
+ end
224
+
225
+ def categorize_position(level)
226
+ # Check ground: trace 1 unit down against world + brush entities
227
+ if @velocity.z > 180
228
+ @on_ground = false
229
+ else
230
+ trace_start = @position
231
+ trace_end = Math::Vec3.new(@position.x, @position.y, @position.z - 1.0)
232
+ result = HullTrace.trace_world_and_entities(
233
+ level, trace_start, trace_end, @brush_entities
234
+ )
235
+
236
+ if result.fraction < 1.0 && result.plane_normal &&
237
+ result.plane_normal.z >= MIN_GROUND_NORMAL_Z
238
+ @on_ground = true
239
+ @position = result.end_pos if !result.start_solid && !result.all_solid
240
+ # Zero out negative z velocity when grounded (matches Quake's
241
+ # PM_CategorizePosition: pmove.velocity[2] = 0)
242
+ if @velocity.z < 0
243
+ @velocity = Math::Vec3.new(@velocity.x, @velocity.y, 0.0)
244
+ end
245
+ else
246
+ @on_ground = false
247
+ end
248
+ end
249
+
250
+ # Check water level using BSP leaf contents (not clipnodes).
251
+ # Hull 1 clipnodes only track solid/empty; water content is in the
252
+ # BSP node/leaf tree which point_in_leaf walks.
253
+ @water_level = 0
254
+ @water_type = CONTENTS_EMPTY
255
+
256
+ # Feet check (player mins z = -24)
257
+ feet_pos = Math::Vec3.new(@position.x, @position.y, @position.z - 23)
258
+ contents = leaf_contents(level, feet_pos)
259
+ if contents <= CONTENTS_WATER
260
+ @water_type = contents
261
+ @water_level = 1
262
+ # Waist check
263
+ mid_pos = Math::Vec3.new(@position.x, @position.y, @position.z + 4)
264
+ if leaf_contents(level, mid_pos) <= CONTENTS_WATER
265
+ @water_level = 2
266
+ # Head check
267
+ head_pos = Math::Vec3.new(@position.x, @position.y, @position.z + VIEW_HEIGHT)
268
+ @water_level = 3 if leaf_contents(level, head_pos) <= CONTENTS_WATER
269
+ end
270
+ end
271
+ end
272
+
273
+ # Get the content type at a point using the BSP node/leaf tree.
274
+ # This correctly detects water, slime, lava (unlike clipnodes).
275
+ def leaf_contents(level, point)
276
+ leaf_idx = Bsp::Vis.point_in_leaf(level, point)
277
+ leaf = level.leafs[leaf_idx]
278
+ leaf ? leaf.contents : CONTENTS_EMPTY
279
+ end
280
+
281
+ def walk_move(dt, level)
282
+ # Desired new position
283
+ desired = @position + @velocity * dt
284
+
285
+ # Trace against world + brush entities
286
+ result = trace_move(level, @position, desired)
287
+
288
+ return if result.all_solid # stuck
289
+
290
+ if result.fraction == 1.0
291
+ @position = desired
292
+ return
293
+ end
294
+
295
+ # Hit something. Try stair stepping.
296
+ contact = result.end_pos
297
+
298
+ # Step up
299
+ step_up = Math::Vec3.new(contact.x, contact.y, contact.z + STEP_SIZE)
300
+ up_trace = trace_move(level, contact, step_up)
301
+ raised = up_trace.end_pos
302
+
303
+ # Move forward from raised position
304
+ step_forward = Math::Vec3.new(desired.x, desired.y, raised.z)
305
+ forward_trace = trace_move(level, raised, step_forward)
306
+ forward_pos = forward_trace.end_pos
307
+
308
+ # Step back down
309
+ step_down = Math::Vec3.new(forward_pos.x, forward_pos.y, forward_pos.z - STEP_SIZE)
310
+ down_trace = trace_move(level, forward_pos, step_down)
311
+
312
+ if down_trace.fraction < 1.0 && down_trace.plane_normal &&
313
+ down_trace.plane_normal.z >= MIN_GROUND_NORMAL_Z
314
+ @position = down_trace.end_pos
315
+ return
316
+ end
317
+
318
+ # Step didn't work - slide along the wall
319
+ @position = contact
320
+ slide_along_wall(result, dt, level)
321
+ end
322
+
323
+ def trace_move(level, from, to)
324
+ HullTrace.trace_world_and_entities(level, from, to, @brush_entities)
325
+ end
326
+
327
+ def slide_along_wall(trace_result, dt, level)
328
+ normal = trace_result.plane_normal
329
+ return unless normal
330
+
331
+ backoff = @velocity.dot(normal)
332
+ @velocity = Math::Vec3.new(
333
+ @velocity.x - normal.x * backoff,
334
+ @velocity.y - normal.y * backoff,
335
+ @velocity.z - normal.z * backoff
336
+ )
337
+
338
+ remaining = @velocity * (dt * (1.0 - trace_result.fraction))
339
+ desired = @position + remaining
340
+ result = trace_move(level, @position, desired)
341
+ @position = result.end_pos
342
+ end
343
+
344
+ def noclip_move(dt, keys)
345
+ wish = Math::Vec3::ORIGIN
346
+ wish = wish + forward if keys[SDL::SCANCODE_W]
347
+ wish = wish - forward if keys[SDL::SCANCODE_S]
348
+ wish = wish + right if keys[SDL::SCANCODE_D]
349
+ wish = wish - right if keys[SDL::SCANCODE_A]
350
+ wish = wish + Math::Vec3.new(0.0, 0.0, 1.0) if keys[SDL::SCANCODE_SPACE]
351
+ wish = wish - Math::Vec3.new(0.0, 0.0, 1.0) if keys[SDL::SCANCODE_C]
352
+
353
+ @position = @position + wish * (MAX_SPEED * dt)
354
+ end
355
+ end
356
+ end
357
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opengl"
4
+
5
+ module Quake
6
+ module Renderer
7
+ # Renders Quake MDL (alias) models with texture mapping and frame animation.
8
+ class GLAliasModel
9
+ # Quake's pre-computed normal table (162 normals for lighting)
10
+ # Simplified to just the first few key directions for now
11
+ ANORMS = nil # Full table would be loaded from anorms.h
12
+
13
+ def initialize(model, palette)
14
+ @model = model
15
+ @palette = palette
16
+ @skin_textures = []
17
+ upload_skins
18
+ end
19
+
20
+ # Render the model at a given frame (interpolation between two frames).
21
+ # frame_index: current frame number
22
+ # lerp: 0.0-1.0 interpolation factor to next frame
23
+ # position: Vec3 world position
24
+ # yaw: rotation angle in degrees
25
+ def render(frame_index:, lerp: 0.0, position: Math::Vec3::ORIGIN, yaw: 0.0, pitch: 0.0, scale: 1.0)
26
+ frame = resolve_frame(frame_index)
27
+ next_frame = resolve_frame(frame_index + 1)
28
+
29
+ GL.PushMatrix
30
+ GL.Translatef(position.x, position.y, position.z)
31
+ GL.Rotatef(yaw, 0.0, 0.0, 1.0)
32
+ GL.Rotatef(pitch, 0.0, 1.0, 0.0)
33
+
34
+ GL.Enable(GL::TEXTURE_2D)
35
+ GL.BindTexture(GL::TEXTURE_2D, @skin_textures[0]) if @skin_textures.any?
36
+
37
+ GL.Begin(GL::TRIANGLES)
38
+ @model.triangles.each do |tri|
39
+ tri.vertex_indices.each_with_index do |vi, _ti|
40
+ # Texture coordinates
41
+ stvert = @model.stverts[vi]
42
+ s = stvert.s.to_f
43
+ t = stvert.t.to_f
44
+
45
+ # Adjust seam UVs for back-facing triangles
46
+ if stvert.on_seam != 0 && tri.faces_front == 0
47
+ s += @model.skin_width * 0.5
48
+ end
49
+
50
+ s = (s + 0.5) / @model.skin_width
51
+ t = (t + 0.5) / @model.skin_height
52
+ GL.TexCoord2f(s, t)
53
+
54
+ # Decompress vertex position
55
+ v1 = frame.vertices[vi]
56
+ x1 = v1.x * @model.scale.x + @model.scale_origin.x
57
+ y1 = v1.y * @model.scale.y + @model.scale_origin.y
58
+ z1 = v1.z * @model.scale.z + @model.scale_origin.z
59
+
60
+ if lerp > 0.0 && next_frame
61
+ v2 = next_frame.vertices[vi]
62
+ x2 = v2.x * @model.scale.x + @model.scale_origin.x
63
+ y2 = v2.y * @model.scale.y + @model.scale_origin.y
64
+ z2 = v2.z * @model.scale.z + @model.scale_origin.z
65
+
66
+ x = x1 + (x2 - x1) * lerp
67
+ y = y1 + (y2 - y1) * lerp
68
+ z = z1 + (z2 - z1) * lerp
69
+ else
70
+ x = x1
71
+ y = y1
72
+ z = z1
73
+ end
74
+
75
+ GL.Vertex3f(x * scale, y * scale, z * scale)
76
+ end
77
+ end
78
+ GL.End
79
+
80
+ GL.PopMatrix
81
+ end
82
+
83
+ def frame_count
84
+ @model.frames.size
85
+ end
86
+
87
+ private
88
+
89
+ def resolve_frame(index)
90
+ return nil if @model.frames.empty?
91
+ entry = @model.frames[index % @model.frames.size]
92
+ case entry
93
+ when Mdl::Frame then entry
94
+ when Mdl::FrameGroup then entry.frames[0] # first frame of group
95
+ end
96
+ end
97
+
98
+ def upload_skins
99
+ @model.skins.each do |skin_variants|
100
+ pixels = skin_variants[0] # use first variant
101
+ next if pixels.nil?
102
+
103
+ rgba = @palette.indexed_to_rgba(pixels)
104
+
105
+ buf = "\0" * 4
106
+ GL.GenTextures(1, buf)
107
+ tex_id = buf.unpack1("V")
108
+
109
+ GL.BindTexture(GL::TEXTURE_2D, tex_id)
110
+ GL.TexImage2D(GL::TEXTURE_2D, 0, GL::RGBA,
111
+ @model.skin_width, @model.skin_height, 0,
112
+ GL::RGBA, GL::UNSIGNED_BYTE, rgba)
113
+ GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_MIN_FILTER, GL::LINEAR_MIPMAP_LINEAR)
114
+ GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_MAG_FILTER, GL::LINEAR)
115
+ GL.GenerateMipmap(GL::TEXTURE_2D)
116
+
117
+ @skin_textures << tex_id
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opengl"
4
+
5
+ module Quake
6
+ module Renderer
7
+ # Renders BSP sub-models (brush entities like doors, buttons, platforms).
8
+ # Each brush entity references a model in the BSP models array (models[1], [2], etc.)
9
+ # and has a position/angle from its entity definition.
10
+ class GLBrushModel
11
+ def initialize(level, texture_manager, lightmap)
12
+ @level = level
13
+ @texture_manager = texture_manager
14
+ @lightmap = lightmap
15
+
16
+ # Precompute surfaces for each sub-model (model index 1+)
17
+ @model_surfaces = {}
18
+ precompute_all_submodels
19
+ end
20
+
21
+ # Render all brush entities at their current positions.
22
+ # entities: array of Entity objects that have model_index set
23
+ def render(entities)
24
+ GL.Enable(GL::TEXTURE_2D)
25
+ GL.Color3f(1.0, 1.0, 1.0)
26
+
27
+ entities.each do |ent|
28
+ next unless ent.brush_entity?
29
+ surfaces = @model_surfaces[ent.model_index]
30
+ next unless surfaces
31
+
32
+ GL.PushMatrix
33
+ GL.Translatef(ent.position.x, ent.position.y, ent.position.z)
34
+
35
+ if ent.angle != 0.0 && ent.angles != Math::Vec3::ORIGIN
36
+ GL.Rotatef(ent.angles.x, 1.0, 0.0, 0.0)
37
+ GL.Rotatef(ent.angles.y, 0.0, 1.0, 0.0)
38
+ GL.Rotatef(ent.angles.z, 0.0, 0.0, 1.0)
39
+ end
40
+
41
+ render_model_surfaces(surfaces)
42
+
43
+ GL.PopMatrix
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def precompute_all_submodels
50
+ @level.models.each_with_index do |model, model_idx|
51
+ next if model_idx == 0 # skip worldmodel, rendered by GLTextured
52
+
53
+ surfaces = []
54
+ model.num_faces.times do |i|
55
+ face_index = model.first_face + i
56
+ face = @level.faces[face_index]
57
+ next unless face
58
+
59
+ texinfo = @level.texinfo[face.texinfo_index]
60
+ next if texinfo.nil?
61
+
62
+ tex = @level.textures[texinfo.miptex_index]
63
+ next if tex.nil?
64
+ next if tex.name == "trigger" || tex.name == "clip"
65
+ next if tex.name.start_with?("sky")
66
+
67
+ surf = Bsp::FaceVertices.extract_surface(@level, face)
68
+ surfaces << {
69
+ vertices: surf.vertices,
70
+ texcoords: surf.texcoords,
71
+ miptex_index: texinfo.miptex_index,
72
+ face_index: face_index,
73
+ texinfo_index: face.texinfo_index
74
+ }
75
+ end
76
+
77
+ @model_surfaces[model_idx] = surfaces unless surfaces.empty?
78
+ end
79
+
80
+ total = @model_surfaces.values.sum(&:size)
81
+ puts "Precomputed #{total} brush model surfaces across #{@model_surfaces.size} sub-models"
82
+ end
83
+
84
+ def render_model_surfaces(surfaces)
85
+ # Group by texture for fewer bind calls
86
+ current_miptex = -1
87
+
88
+ if @lightmap
89
+ render_surfaces_with_lightmaps(surfaces)
90
+ else
91
+ surfaces.each do |surf|
92
+ if surf[:miptex_index] != current_miptex
93
+ @texture_manager.bind(surf[:miptex_index])
94
+ current_miptex = surf[:miptex_index]
95
+ end
96
+
97
+ GL.Begin(GL::TRIANGLE_FAN)
98
+ surf[:vertices].each_with_index do |v, i|
99
+ u, t = surf[:texcoords][i]
100
+ GL.TexCoord2f(u, t)
101
+ GL.Vertex3f(v.x, v.y, v.z)
102
+ end
103
+ GL.End
104
+ end
105
+ end
106
+ end
107
+
108
+ def render_surfaces_with_lightmaps(surfaces)
109
+ # Pass 1: diffuse textures
110
+ GL.TexEnvi(GL::TEXTURE_ENV, GL::TEXTURE_ENV_MODE, GL::REPLACE)
111
+ current_miptex = -1
112
+
113
+ surfaces.each do |surf|
114
+ if surf[:miptex_index] != current_miptex
115
+ @texture_manager.bind(surf[:miptex_index])
116
+ current_miptex = surf[:miptex_index]
117
+ end
118
+
119
+ GL.Begin(GL::TRIANGLE_FAN)
120
+ surf[:vertices].each_with_index do |v, i|
121
+ u, t = surf[:texcoords][i]
122
+ GL.TexCoord2f(u, t)
123
+ GL.Vertex3f(v.x, v.y, v.z)
124
+ end
125
+ GL.End
126
+ end
127
+
128
+ # Pass 2: lightmap multiply
129
+ GL.Enable(GL::BLEND)
130
+ GL.BlendFunc(GL::ZERO, GL::SRC_COLOR)
131
+ GL.DepthMask(GL::FALSE)
132
+ GL.DepthFunc(GL::LEQUAL)
133
+
134
+ last_lm_tex = -1
135
+ surfaces.each do |surf|
136
+ face_idx = surf[:face_index]
137
+ next unless @lightmap.face_lightmaps[face_idx]
138
+
139
+ lm_info = @lightmap.face_lightmaps[face_idx]
140
+ if lm_info.gl_texture != last_lm_tex
141
+ GL.BindTexture(GL::TEXTURE_2D, lm_info.gl_texture)
142
+ last_lm_tex = lm_info.gl_texture
143
+ end
144
+
145
+ texinfo = @level.texinfo[surf[:texinfo_index]]
146
+ GL.Begin(GL::TRIANGLE_FAN)
147
+ surf[:vertices].each_with_index do |v, i|
148
+ ls, lt = @lightmap.lightmap_texcoords(face_idx, v, texinfo)
149
+ GL.TexCoord2f(ls, lt)
150
+ GL.Vertex3f(v.x, v.y, v.z)
151
+ end
152
+ GL.End
153
+ end
154
+
155
+ GL.DepthMask(GL::TRUE)
156
+ GL.DepthFunc(GL::LESS)
157
+ GL.Disable(GL::BLEND)
158
+ GL.TexEnvi(GL::TEXTURE_ENV, GL::TEXTURE_ENV_MODE, GL::MODULATE)
159
+ end
160
+ end
161
+ end
162
+ end