quake-rb 0.1.0 → 0.2.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.
@@ -11,28 +11,115 @@ module Quake
11
11
  class GLLightmap
12
12
  LIGHTMAP_BLOCK_WIDTH = 128
13
13
  LIGHTMAP_BLOCK_HEIGHT = 128
14
-
15
- LightmapInfo = Data.define(:gl_texture, :s_offset, :t_offset, :width, :height)
16
-
17
- attr_reader :face_lightmaps # face_index -> LightmapInfo
18
-
19
- def initialize(level, palette)
14
+ MAX_LIGHTSTYLES = 64
15
+ DEFAULT_LIGHTSTYLES = {
16
+ 0 => "m",
17
+ 1 => "mmnmmommommnonmmonqnmmo",
18
+ 2 => "abcdefghijklmnopqrstuvwxyzyxwvutsrqponmlkjihgfedcba",
19
+ 3 => "mmmmmaaaaammmmmaaaaaabcdefgabcdefg",
20
+ 4 => "mamamamamama",
21
+ 5 => "jklmnopqrstuvwxyzyxwvutsrqponmlkj",
22
+ 6 => "nmonqnmomnmomomno",
23
+ 7 => "mmmaaaabcdefgmmmmaaaammmaamm",
24
+ 8 => "mmmaaammmaaammmabcdefaaaammmmabcdefmmmaaaa",
25
+ 9 => "aaaaaaaazzzzzzzz",
26
+ 10 => "mmamammmmammamamaaamammma",
27
+ 11 => "abcdefghijklmnopqrrqponmlkjihgfedcba",
28
+ 63 => "a"
29
+ }.freeze
30
+
31
+ LightmapInfo = Data.define(:gl_texture, :block_index, :s_offset, :t_offset, :width, :height)
32
+
33
+ attr_reader :face_lightmaps, :lightstyle_values # face_index -> LightmapInfo
34
+
35
+ def initialize(level, palette, lightstyles: nil)
20
36
  @level = level
21
37
  @palette = palette
22
38
  @face_lightmaps = {}
23
39
  @block_textures = [] # GL texture ids for each block
24
40
  @block_allocated = [] # row allocation per block column
25
41
  @block_pixels = [] # RGBA pixel data per block
42
+ @face_lightmap_faces = {}
43
+ @uploaded = false
44
+ @lightstyles = self.class.default_lightstyles
45
+ Array(lightstyles).each_with_index { |map, style| @lightstyles[style] = map if map }
46
+ animate_lightstyles(0.0)
47
+ end
48
+
49
+ def self.default_lightstyles
50
+ Array.new(MAX_LIGHTSTYLES) { |style| DEFAULT_LIGHTSTYLES[style] }
51
+ end
52
+
53
+ def self.lightstyle_values_for(time, lightstyles)
54
+ frame = (time.to_f * 10.0).to_i
55
+ values = Array.new(MAX_LIGHTSTYLES, 256)
56
+ MAX_LIGHTSTYLES.times do |style|
57
+ map = lightstyles[style]
58
+ next if map.nil? || map.empty?
59
+
60
+ values[style] = (map.getbyte(frame % map.length) - "a".ord) * 22
61
+ end
62
+ values
63
+ end
64
+
65
+ def lightstyles=(lightstyles)
66
+ Array(lightstyles).each_with_index { |map, style| @lightstyles[style] = map if style < MAX_LIGHTSTYLES }
67
+ end
68
+
69
+ def set_lightstyle(style, map)
70
+ return if style.negative? || style >= MAX_LIGHTSTYLES
71
+
72
+ @lightstyles[style] = map
73
+ end
74
+
75
+ def animate_lightstyles(time)
76
+ @lightstyle_values = self.class.lightstyle_values_for(time, @lightstyles)
77
+ @lightstyle_values
78
+ end
79
+
80
+ def update(time, lightstyles: nil, dynamic_lights: nil)
81
+ self.lightstyles = lightstyles if lightstyles
82
+ old_values = @lightstyle_values || Array.new(MAX_LIGHTSTYLES, 256)
83
+ new_values = animate_lightstyles(time)
84
+ changed_styles = (0...MAX_LIGHTSTYLES).select { |style| old_values[style] != new_values[style] }
85
+
86
+ # R_MarkLights: faces touched by dynamic lights this frame, plus faces
87
+ # lit last frame (which must be re-baked back to their static light).
88
+ dlit_faces = mark_dynamic_faces(Array(dynamic_lights))
89
+ previously_dlit = @dlit_faces || {}
90
+ @dlit_faces = dlit_faces
91
+ return false if changed_styles.empty? && dlit_faces.empty? && previously_dlit.empty?
92
+
93
+ changed_blocks = {}
94
+ @face_lightmap_faces.each do |face_index, face|
95
+ lights = dlit_faces[face_index]
96
+ needs_rebake = lights ||
97
+ previously_dlit.key?(face_index) ||
98
+ (!changed_styles.empty? && face_uses_styles?(face, changed_styles))
99
+ next unless needs_rebake
100
+
101
+ info = @face_lightmaps[face_index]
102
+ next unless info
103
+
104
+ write_face_lightmap(face, info, face_index: face_index, dlights: lights)
105
+ changed_blocks[info.block_index] = true
106
+ end
107
+ upload_changed_blocks(changed_blocks.keys) if @uploaded
108
+ !changed_blocks.empty?
26
109
  end
27
110
 
28
111
  def build_all
112
+ animate_lightstyles(0.0) unless @lightstyle_values
29
113
  allocate_block
30
114
 
31
115
  @level.faces.each_with_index do |face, face_index|
32
116
  texinfo = @level.texinfo[face.texinfo_index]
33
117
  next if texinfo.nil?
34
118
  next if texinfo.flags & 1 != 0 # TEX_SPECIAL (sky/turb)
35
- next if face.light_offset < 0
119
+ # Faces with no samples (light_offset < 0) still get a lightmap: all
120
+ # zeros renders them black, matching R_BuildLightMap. Only a missing
121
+ # lighting lump means fullbright.
122
+ next if @level.lighting.nil? || @level.lighting.empty?
36
123
 
37
124
  build_face_lightmap(face, face_index)
38
125
  end
@@ -57,15 +144,16 @@ module Quake
57
144
  s = vertex.dot(texinfo.s_vec) + texinfo.s_offset
58
145
  t = vertex.dot(texinfo.t_vec) + texinfo.t_offset
59
146
 
60
- # Convert to lightmap UV: subtract texture mins, scale by 1/16, offset by 0.5
61
- # Then convert to atlas UV
147
+ # Convert to lightmap UV: subtract texture mins, scale by 1/16.
148
+ # The half-luxel centering is applied once, as the +0.5 texel below
149
+ # (C adds +8 texture units before the /16 -- same thing).
62
150
  extents = face_extents(face_index)
63
151
  return [0.0, 0.0] unless extents
64
152
 
65
153
  tex_mins_s, tex_mins_t, _, _ = extents
66
154
 
67
- ls = (s - tex_mins_s + 8.0) / (info.width * 16.0)
68
- lt = (t - tex_mins_t + 8.0) / (info.height * 16.0)
155
+ ls = (s - tex_mins_s) / (info.width * 16.0)
156
+ lt = (t - tex_mins_t) / (info.height * 16.0)
69
157
 
70
158
  # Map into atlas block coordinates
71
159
  atlas_s = (info.s_offset + ls * info.width + 0.5) / LIGHTMAP_BLOCK_WIDTH
@@ -168,7 +256,7 @@ module Quake
168
256
  extents = calc_extents(face)
169
257
  return unless extents
170
258
 
171
- tex_min_s, tex_min_t, extent_s, extent_t = extents
259
+ _tex_min_s, _tex_min_t, extent_s, extent_t = extents
172
260
  lm_width = (extent_s / 16) + 1
173
261
  lm_height = (extent_t / 16) + 1
174
262
 
@@ -178,55 +266,141 @@ module Quake
178
266
  @extents_cache ||= {}
179
267
  @extents_cache[face_index] = extents
180
268
 
181
- # Read lightmap data from lighting lump
182
- lm_size = lm_width * lm_height
269
+ block_idx, s_off, t_off = find_space(lm_width, lm_height)
270
+ info = LightmapInfo.new(
271
+ gl_texture: nil,
272
+ block_index: block_idx,
273
+ s_offset: s_off, t_offset: t_off,
274
+ width: lm_width, height: lm_height
275
+ )
276
+
277
+ @face_lightmaps[face_index] = info
278
+ @face_lightmap_faces[face_index] = face
279
+ write_face_lightmap(face, info, face_index: face_index)
280
+ end
281
+
282
+ def write_face_lightmap(face, info, face_index: nil, dlights: nil)
283
+ lm_size = info.width * info.height
183
284
  lighting = @level.lighting
184
285
  offset = face.light_offset
185
-
186
- # Accumulate light from all styles
187
286
  blocklights = Array.new(lm_size, 0)
188
287
 
189
- 4.times do |style_idx|
190
- style = face.styles[style_idx]
191
- break if style == 255 # end of styles
192
-
193
- # Scale factor: normal light (style 0) = 264 (Quake default)
194
- # Other styles animated, but default to full brightness
195
- scale = 256
288
+ if offset >= 0
289
+ 4.times do |style_idx|
290
+ style = face.styles[style_idx]
291
+ break if style == 255 # end of styles
196
292
 
197
- lm_size.times do |j|
198
- byte = lighting.getbyte(offset + j)
199
- blocklights[j] += byte * scale if byte
293
+ scale = @lightstyle_values.fetch(style, 256)
294
+ lm_size.times do |j|
295
+ byte = lighting.getbyte(offset + j)
296
+ blocklights[j] += byte * scale if byte
297
+ end
298
+ offset += lm_size
200
299
  end
201
- offset += lm_size
202
300
  end
203
301
 
204
- # Find space in lightmap atlas
205
- block_idx, s_off, t_off = find_space(lm_width, lm_height)
302
+ add_dynamic_lights(face, face_index, info, blocklights, dlights) if dlights && face_index
206
303
 
207
- # Write pixels into atlas block
208
- pixels = @block_pixels[block_idx]
209
- lm_height.times do |row|
210
- lm_width.times do |col|
211
- src_idx = row * lm_width + col
212
- # Shift right 7 (overbright by 2x) to match Quake's gamma
213
- val = (blocklights[src_idx] >> 7).clamp(0, 255)
304
+ pixels = @block_pixels[info.block_index]
305
+ info.height.times do |row|
306
+ info.width.times do |col|
307
+ src_idx = row * info.width + col
308
+ val = 255 - (blocklights[src_idx] >> 7).clamp(0, 255)
214
309
 
215
- dst_x = s_off + col
216
- dst_y = t_off + row
310
+ dst_x = info.s_offset + col
311
+ dst_y = info.t_offset + row
217
312
  dst_idx = (dst_y * LIGHTMAP_BLOCK_WIDTH + dst_x) * 4
218
- pixels.setbyte(dst_idx, val)
219
- pixels.setbyte(dst_idx + 1, val)
220
- pixels.setbyte(dst_idx + 2, val)
221
- pixels.setbyte(dst_idx + 3, 255)
313
+ pixels.setbyte(dst_idx + 3, val)
222
314
  end
223
315
  end
316
+ end
224
317
 
225
- @face_lightmaps[face_index] = LightmapInfo.new(
226
- gl_texture: block_idx, # Will be replaced with actual GL id after upload
227
- s_offset: s_off, t_offset: t_off,
228
- width: lm_width, height: lm_height
229
- )
318
+ # R_MarkLights: walk the BSP tree per light, collecting faces on nodes
319
+ # the light sphere straddles. Returns face_index -> [lights].
320
+ def mark_dynamic_faces(dlights)
321
+ return {} if dlights.empty? || @level.nodes.empty? || @level.planes.empty?
322
+
323
+ marked = {}
324
+ dlights.each { |light| mark_light(light, 0, marked) }
325
+ marked
326
+ end
327
+
328
+ def mark_light(light, node_index, marked)
329
+ return if node_index.negative? # leaf
330
+
331
+ node = @level.nodes[node_index]
332
+ return unless node
333
+
334
+ plane = @level.planes[node.plane_index]
335
+ return unless plane
336
+
337
+ radius = light.radius.to_f
338
+ dist = light.origin.dot(plane.normal) - plane.dist
339
+ return mark_light(light, node.children[0], marked) if dist > radius
340
+ return mark_light(light, node.children[1], marked) if dist < -radius
341
+
342
+ node.num_faces.times do |i|
343
+ face_index = node.first_face + i
344
+ (marked[face_index] ||= []) << light
345
+ end
346
+
347
+ mark_light(light, node.children[0], marked)
348
+ mark_light(light, node.children[1], marked)
349
+ end
350
+
351
+ # R_AddDynamicLights: project each light onto the face plane and add
352
+ # (rad - dist) * 256 per luxel inside the light's falloff.
353
+ def add_dynamic_lights(face, face_index, info, blocklights, dlights)
354
+ extents = @extents_cache&.[](face_index)
355
+ return unless extents
356
+
357
+ tex_mins_s, tex_mins_t, = extents
358
+ texinfo = @level.texinfo[face.texinfo_index]
359
+ plane = @level.planes[face.plane_index]
360
+ return unless texinfo && plane
361
+
362
+ smax = info.width
363
+ tmax = info.height
364
+
365
+ dlights.each do |light|
366
+ rad = light.radius.to_f
367
+ dist = light.origin.dot(plane.normal) - plane.dist
368
+ rad -= dist.abs
369
+ minlight = light.minlight.to_f
370
+ next if rad < minlight
371
+
372
+ minlight = rad - minlight
373
+ impact = light.origin - plane.normal * dist
374
+ local_s = impact.dot(texinfo.s_vec) + texinfo.s_offset - tex_mins_s
375
+ local_t = impact.dot(texinfo.t_vec) + texinfo.t_offset - tex_mins_t
376
+
377
+ tmax.times do |t|
378
+ td = (local_t - t * 16).abs
379
+ smax.times do |s|
380
+ sd = (local_s - s * 16).abs
381
+ d = sd > td ? sd + td / 2 : td + sd / 2
382
+ blocklights[t * smax + s] += ((rad - d) * 256).to_i if d < minlight
383
+ end
384
+ end
385
+ end
386
+ end
387
+
388
+ def face_uses_styles?(face, changed_styles)
389
+ face.styles.any? { |style| style != 255 && changed_styles.include?(style) }
390
+ end
391
+
392
+ def upload_changed_blocks(block_indices)
393
+ block_indices.each do |block_index|
394
+ info = @face_lightmaps.values.find { |face_info| face_info.block_index == block_index }
395
+ next unless info&.gl_texture
396
+
397
+ GL.BindTexture(GL::TEXTURE_2D, info.gl_texture)
398
+ GL.TexSubImage2D(
399
+ GL::TEXTURE_2D, 0, 0, 0,
400
+ LIGHTMAP_BLOCK_WIDTH, LIGHTMAP_BLOCK_HEIGHT,
401
+ GL::RGBA, GL::UNSIGNED_BYTE, @block_pixels[block_index]
402
+ )
403
+ end
230
404
  end
231
405
 
232
406
  def upload_blocks
@@ -250,11 +424,13 @@ module Quake
250
424
  # Update face lightmap infos with actual GL texture ids
251
425
  @face_lightmaps.each do |face_index, info|
252
426
  @face_lightmaps[face_index] = LightmapInfo.new(
253
- gl_texture: gl_ids[info.gl_texture],
427
+ gl_texture: gl_ids[info.block_index],
428
+ block_index: info.block_index,
254
429
  s_offset: info.s_offset, t_offset: info.t_offset,
255
430
  width: info.width, height: info.height
256
431
  )
257
432
  end
433
+ @uploaded = true
258
434
  end
259
435
  end
260
436
  end