Created
March 11, 2014 22:26
-
-
Save nilium/9496419 to your computer and use it in GitHub Desktop.
Quickly thrown-together script for converting MD3 files to OBJ files. Each frame gets its own file. This script is very slow.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby -WKu | |
require 'stringio' | |
module MD3View | |
def run_view(*) ; raise NotImplementedError ; end | |
end | |
begin | |
require("#{File.dirname $0}/md3view.rb") | |
rescue LoadError => ex | |
$stderr.puts ex | |
end | |
# Simple IO ops for reading MD3 files. May be included in anything that | |
# implements a read(numbytes)->string method. | |
module QIO | |
class <<self | |
def __define_read_op__(name, size, packing) | |
__send__(:define_method, name, -> (n = nil) do | |
pkg = self.read((n || 1) * size).unpack(packing) | |
n.nil? ? pkg[0] : pkg | |
end) | |
end | |
private :__define_read_op__ | |
# write_md3_frame_obj writes the md3 data's frame to io in the Wavefront OBJ | |
# format. | |
def write_md3_frame_obj(io, data, frame) | |
io.puts "o #{data.name}" | |
vcount = 1 | |
data.surfaces.each do |surf| | |
io.puts "g #{surf.name}" | |
surf.xyznormals[frame].each do |vert| | |
io.puts "v #{vert.origin.join ' '}" | |
end | |
surf.xyznormals[frame].each do |vert| | |
io.puts "vn #{vert.normal.map{|i|-i}.join ' '}" | |
end | |
surf.texcoords.each do |st| | |
io.puts "vt #{st.join ' '}" | |
end | |
surf.triangles.each do |tri| | |
io.puts "f #{tri.to_a.reverse.map { |i| i += vcount; [i, i, i].join '/' }.join ' '}" | |
end | |
vcount += surf.texcoords.length | |
end | |
end | |
end | |
MD3_XYZ_SCALE = 1.0 / 64.0 | |
FIXED_TO_FLOAT_BLK = -> (s16) { s16 * MD3_XYZ_SCALE } | |
FIXED_TO_SPHERE_BLK = -> (components) do | |
zenith = components[0].to_f | |
azimuth = components[1].to_f | |
latitude = zenith * (Math::PI * 2.0) / 255.0 | |
longitude = azimuth * (Math::PI * 2.0) / 255.0 | |
latcos = Math.cos(latitude) | |
components[0] = Math.cos(longitude) * Math.sin(latitude) | |
components[1] = Math.sin(longitude) * latcos | |
components[2] = latcos | |
components.each_with_index do |k, i| | |
if (1.0 - k).abs < 1e-6 | |
components[(i + 1) % 3] = 0.0 | |
components[(i + 2) % 3] = 0.0 | |
break | |
end | |
end | |
components | |
end | |
__define_read_op__(:read_u8, 1, 'C*') | |
__define_read_op__(:read_s16, 2, 's<*') | |
__define_read_op__(:read_s32, 4, 'l<*') | |
__define_read_op__(:read_f32, 4, 'e*') | |
def read_vec3_f32(n = nil) | |
pkg = read_f32((n || 1) * 3).each_slice(3).to_a | |
n.nil? ? pkg[0] : pkg | |
end | |
def read_vec3_f16(n = nil, normal = false) | |
pkg = read_s16((n || 1) * 3) | |
pkg = pkg.map!(&FIXED_TO_FLOAT_BLK).each_slice(3).to_a | |
n.nil? ? pkg[0] : pkg | |
end | |
def read_sphere_normal(n = nil) | |
pkg = read_u8((n || 1) * 2).each_slice(2).to_a | |
pkg.each(&FIXED_TO_SPHERE_BLK) | |
n.nil? ? pkg[0] : pkg | |
end | |
def read_nul_string(bytes) | |
str = read(bytes) | |
str[0 ... (str.index("\0") || str.length)] | |
end | |
end | |
class QStringIO < StringIO | |
include QIO | |
end | |
class MD3Data | |
MD3_IDENT = "IDP3" | |
SURF_IDENT = MD3_IDENT | |
MAX_QPATH = 64 | |
Chunk = Struct.new(:offset, :count) | |
# vertices is an array of length chunks.frames.count containing arrays of vertices. | |
# triangles is an array of length chunks.triangles.count containing 3-long arrays of vertex indices. | |
Surface = Struct.new(:name, :chunks, :frames, :shaders, :triangles, :texcoords, :xyznormals) | |
Frame = Struct.new(:min, :max, :origin, :radius, :name) | |
Vertex = Struct.new(:origin, :normal) | |
Shader = Struct.new(:name, :index) | |
Tag = Struct.new(:name, :frames) | |
TagFrame = Struct.new(:origin, :orientation) | |
Orientation = Struct.new(:x, :y, :z) | |
PRIVATE_ATTRS = %i[ io data ] | |
attr_reader *PRIVATE_ATTRS | |
private *PRIVATE_ATTRS | |
attr_reader *%i[ name version flags chunks frames surfaces tags ] | |
def frame_name(frame) | |
frames[frame].name | |
end | |
def initialize(data) | |
@data = data | |
@io = QStringIO.new(data, 'rb') | |
start = io.tell | |
ident = io.read(4) | |
raise "Invalid MD3 header IDENT: #{ident}" unless ident == MD3_IDENT | |
@version = io.read_s32 | |
@name = io.read_nul_string(MAX_QPATH) | |
@flags = io.read_s32 | |
raise "Flags non-zero (#{@flags}) -- not sure how to proceed" unless @flags == 0 | |
@chunks = { | |
header: Chunk[start], | |
frames: Chunk[nil, io.read_s32], | |
tags: Chunk[nil, io.read_s32], | |
surfaces: Chunk[nil, io.read_s32], | |
eof: Chunk[] | |
} | |
skin_count = io.read_s32 | |
raise "Skin count (#{skin_count}) > 0 -- not sure how to proceed" unless skin_count == 0 | |
chunks[:frames].offset = start + io.read_s32 | |
chunks[:tags].offset = start + io.read_s32 | |
chunks[:surfaces].offset = start + io.read_s32 | |
@frames = at_chunk(chunks[:frames]) { |c| read_frames(c.count) } | |
@surfaces = at_chunk(chunks[:surfaces]) { |c| read_surfaces(c.count) } | |
@tags = at_chunk(chunks[:tags]) { |c| read_tags(c.count) } | |
@io = nil | |
@data = nil | |
end | |
def export_obj(io) | |
puts "Not implemented" | |
end | |
private | |
def read_frame | |
Frame[ | |
io.read_vec3_f32, | |
io.read_vec3_f32, | |
io.read_vec3_f32, | |
io.read_f32, | |
io.read_nul_string(16) | |
] | |
end | |
def read_frames(count) | |
Array.new(count) { read_frame } | |
end | |
def read_triangle | |
io.read_s32(3) | |
end | |
def read_triangles(count) | |
Array.new(count) { read_triangle } | |
end | |
def read_vertex | |
Vertex[io.read_vec3_f16, io.read_sphere_normal].tap do |v| | |
t = v.origin[1] | |
v.origin[1] = -v.origin[2] | |
v.origin[2] = t | |
t = v.normal[1] | |
v.normal[1] = -v.normal[2] | |
v.normal[2] = t | |
end | |
end | |
def read_vertices(count) | |
Array.new(count) { read_vertex } | |
end | |
def read_shader | |
Shader[io.read_nul_string(MAX_QPATH), io.read_s32] | |
end | |
def read_shaders(count) | |
Array.new(count) { read_shader } | |
end | |
def read_texcoord | |
pkg = io.read_f32(2) | |
pkg[1] = 1.0 - pkg[1] | |
pkg | |
end | |
def read_texcoords(count) | |
Array.new(count) { read_texcoord } | |
end | |
def read_surface | |
start = io.tell | |
ident = io.read(4) | |
raise "Invalid surface IDENT: #{ident}" unless ident == SURF_IDENT | |
name = io.read_nul_string(MAX_QPATH) | |
flags = io.read_s32 | |
raise "Flags non-zero (#{flags}) -- not sure how to proceed" unless flags == 0 | |
frames = io.read_s32 | |
num_verts = 0 | |
surf_chunks = { | |
start: Chunk[start], | |
shaders: Chunk[nil, io.read_s32], | |
xyznormal: Chunk[nil, (num_verts = io.read_s32)], | |
texcoords: Chunk[nil, num_verts], | |
triangles: Chunk[nil, io.read_s32], | |
eos: Chunk[] | |
} | |
surf_chunks[:triangles].offset = start + io.read_s32 | |
surf_chunks[:shaders].offset = start + io.read_s32 | |
surf_chunks[:texcoords].offset = start + io.read_s32 | |
surf_chunks[:xyznormal].offset = start + io.read_s32 | |
surf_chunks[:eos].offset = start + io.read_s32 | |
Surface[ | |
name, | |
surf_chunks, | |
frames, | |
at_chunk(surf_chunks[:shaders]) { |c| read_shaders(c.count) }, | |
at_chunk(surf_chunks[:triangles]) { |c| read_triangles(c.count) }, | |
at_chunk(surf_chunks[:texcoords]) { |c| read_texcoords(c.count) }, | |
at_chunk(surf_chunks[:xyznormal]) do |c| | |
Array.new(frames) { read_vertices(c.count) } | |
end | |
].tap do | |
# Check that I've read to the end of the surface | |
pos = io.tell | |
raise "Failed to read entire surface" if pos < surf_chunks[:eos].offset | |
raise "Read past end of surface" if pos > surf_chunks[:eos].offset | |
end | |
end | |
def read_surfaces(count) | |
Array.new(count) { read_surface } | |
end | |
def read_tag | |
[ | |
io.read_nul_string(MAX_QPATH), | |
TagFrame[ | |
io.read_vec3_f32, | |
Orientation[*io.read_vec3_f32(3)] | |
] | |
] | |
end | |
def read_tags(count) | |
tags = [] | |
tmap = Hash.new { |h, n| h[n] = Tag[n, []].tap { |t| tags << t } } | |
(0 ... count).each { | |
name, tframe = read_tag | |
tag = tmap[name] | |
tag.frames << tag | |
} | |
tags | |
end | |
def at_chunk(chunk, return_to_current = false) | |
raise ArgumentError, "No block given" unless block_given? | |
pos = io.tell | |
begin | |
io.seek(chunk.offset) | |
yield(chunk) | |
ensure | |
io.seek(pos) if return_to_current | |
end | |
end | |
end | |
module MD3Convert | |
def load_md3(md3path) | |
data = md3path == '-' ? $stdin.read : File.open(md3path, 'r') { |io| io.read } | |
MD3Data.new(data) | |
end | |
def log_md3_spec(data, indent_n = 0) | |
indent = ' ' * indent_n | |
puts <<-EOS | |
#{indent}MD3 #{data.name.empty? ? '(no name)' : data.name}: | |
#{indent} Frames: #{data.frames.length.to_s.rjust(16)} | |
#{indent} Tags[#{data.tags.length}]: | |
EOS | |
data.tags.each do |tag| | |
puts <<-EOS | |
#{indent} #{tag.name.empty? ? '(no name)' : tag.name} | |
EOS | |
end | |
puts "#{indent} Surfaces[#{data.surfaces.length}]:" | |
data.surfaces.each do |surf| | |
puts <<-EOS | |
#{indent} Surface #{surf.name.empty? ? '(no name)' : surf.name}: | |
#{indent} Frames: #{surf.frames.to_s.rjust(10)} | |
#{indent} Vertices: #{surf.xyznormals[0].length.to_s.rjust(10)} | |
#{indent} Triangles: #{surf.triangles.length.to_s.rjust(10)} | |
#{indent} Shaders[#{surf.shaders.length}]: | |
EOS | |
surf.shaders.each do |shader| | |
puts <<-EOS | |
#{indent} #{shader.index.to_s(10).ljust(6)} #{shader.name.empty? ? '(no name)' : shader.name} | |
EOS | |
end | |
end | |
end | |
def run_convert(argv, spec = false) | |
argv.each { |md3path| | |
basename = File.basename(md3path, File.extname(md3path)) | |
dir = "#{File.dirname(md3path)}/#{basename}_frames" | |
basepath = "#{dir}/#{basename}" | |
md3data = load_md3(md3path) | |
if spec | |
puts "File #{md3path.inspect}" | |
log_md3_spec(md3data, 2) | |
next | |
end | |
mkdirp(dir) | |
num_frames = md3data.frames.length | |
(0 ... num_frames).each do |frame| | |
suffix = if num_frames > 1 ; "+#{frame}" ; end | |
puts "Writing frame to '#{basepath}#{suffix}.obj'" | |
export_obj = File.open("#{basepath}#{suffix}.obj", 'w') do |io| | |
QIO.write_md3_frame_obj(io, md3data, frame) | |
end | |
end | |
} | |
end | |
extend self | |
end | |
if __FILE__ == $0 | |
# mkdirp checks that every directory in dirpath exists and is a directory. If | |
# the directory doesn't exist, it creates it. If the path exists but isn't a | |
# directory, an ArgumentError is raised since the dirpath is invalid. | |
def mkdirp(dirpath) | |
dirpath.split('/').each_with_object('') do |comp, accum| | |
accum << "#{comp}/" | |
next if comp == '.' | |
next if File.directory? accum | |
if File.exists? accum | |
raise ArgumentError, "#{accum.inspect} already exists and is not a directory" | |
end | |
Dir.mkdir(accum) | |
end | |
end | |
argv = Marshal.load(Marshal.dump(ARGV)) | |
# help text | |
if argv.empty? || argv.include?('-help') | |
$stderr.puts <<-HELPTEXT.gsub(/^ {4}/, '') | |
md3.rb [-convert] file.md3 [-] [...] | |
Converts the given MD3 files to Wavefront OBJ files. OBJ files are | |
written to 'dir/of/file/basename_frames/basename+frame.obj' to avoid | |
cluttering up the MD3 file's directory too much. | |
If '-' is passed as an argument to md3.rb, the file will be read from | |
standard input. | |
md3.rb -spec [file.md3] [-] [...] | |
md3.rb -view [file.md3 | -] | |
Loads the given md3 file and displays it in a window. | |
md3.rb -help | |
If '-help' is specified, this text is displayed and the program will | |
exit without reading anything. | |
HELPTEXT | |
exit 0 | |
end | |
command = | |
case argv.first | |
when '-convert' | |
argv.shift | |
MD3Convert.run_convert(argv, false) | |
when '-spec' | |
argv.shift | |
MD3Convert.run_convert(argv, true) | |
when '-view' | |
argv.shift | |
MD3View.run_view(argv) | |
else | |
MD3Convert.run_convert(argv, false) | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment