glslkit-webgl 0.1.0.pre

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6ae64d28127a6a9f72c2c3017ba1b7e11739e7f374634be7e2a6a6eecbf663a7
4
+ data.tar.gz: 152a82f8f5be95b90b2d15a8c9b5976d55737164e7d6b69060b89433cfcb56da
5
+ SHA512:
6
+ metadata.gz: c97c9dcbd8d95bd2257241915974ee053994659281f49da8e78df7a4a2b594e377587410d31a7008f6ec994a3ee0b8c91be93a05339e93aecce6ad881409053b
7
+ data.tar.gz: 16c71ae9f9f16e146c5d82b4e4e8fbdab1af3ca6e584ab263576c87a929d88503d02d729758ff5ae18ead15a67dcdb75a627e40fb0ea68e5c0675b0e971ea5a1
data/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ 初回リリース (v0.1)。**ruby.wasm 環境専用。** 通常の CRuby/JRuby/TruffleRuby
6
+ にはインストールできるが、`require "js"` に依存するため実行できない。
7
+
8
+ ### 追加
9
+
10
+ - `Glslkit::WebGL::Context` — `glslkit` の reflection マニフェストから
11
+ Program/Geometry/Textureを構築。`getActiveUniform`/`getActiveAttrib` を
12
+ 呼ばない。`ctx.loop` は `requestAnimationFrame` へ Ruby の `Proc` を直接渡す
13
+ - `Glslkit::WebGL::Program` — uniform設定は再利用する型付き配列に書き込み、
14
+ 毎フレームの新規確保を避ける。コンパイル/リンクエラーは
15
+ `Glslkit::WebGL::CompileError`/`LinkError` として、`SourceMap` 経由で
16
+ 元ファイル名・行番号付きで発生する
17
+ - `Glslkit::WebGL::Geometry` — 頂点データは構築時に型付き配列へ一括転送
18
+ (`Array#to_js` → `Float32Array.from`)。ミューテーションAPIは提供しない
19
+ - `Glslkit::WebGL::Texture`、破壊的更新の行列ヘルパ (`Glslkit::WebGL::Matrix`)
20
+ - `Glslkit::WebGL::Embed`(`rake glslkit:embed`)— `.vert`/`.frag` から
21
+ マニフェスト・SourceMap込みのデータ専用Rubyモジュールを生成し、ホスト側で
22
+ wasmに埋め込めるようにする
23
+ - `Context#reload_program` / `#program_for` — 実行中のシェーダを
24
+ Geometry・uniform値を保ったまま差し替える。attribute locationが変わる等の
25
+ 互換性が無い変更は `ReloadIncompatibleError` を投げる
26
+ - `Glslkit::WebGL::LiveReload`(`require "glslkit/webgl/live_reload"`、
27
+ 明示requireでのみ有効)— `glslkit-rails` のライブリロードエンドポイントを
28
+ ポーリングし、変更を検知したら自動で `reload_program` を呼ぶ
29
+ - サンプル: 回転する立方体 (`sample/index.html`)、レイマーチングデモ
30
+ (`sample/neon.html`)、include先のコンパイルエラーが元ファイル名・行番号を
31
+ 指すことを示すデモ (`sample/neon-error.html`)
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kyubey1228
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # glslkit-webgl
2
+
3
+ `glslkit-webgl` is a WebGL2 runtime for Ruby programs running in a browser with
4
+ ruby.wasm. It consumes a `glslkit` reflection manifest, caches WebGL references,
5
+ and reports shader compiler errors as Ruby exceptions.
6
+
7
+ ## Quick start
8
+
9
+ Serve this repository over HTTP and open `webgl/sample/index.html`. The sample
10
+ draws a rotating, textured cube without application JavaScript, built from
11
+ `webgl/sample/shaders/cube.{vert,frag}` via `rake glslkit:embed`.
12
+ `webgl/sample/neon.html` is an animated fragment-shader raymarching demo, built
13
+ from `webgl/sample/shaders/neon.*` the same way.
14
+ `webgl/sample/neon-error.html` intentionally breaks `common/sdf.glsl` (an
15
+ `#include`d file, not the top-level shader) to demonstrate that `CompileError`
16
+ reports the original included file and line, with a button to switch back to
17
+ the working shaders and start rendering.
18
+
19
+ ```html
20
+ <canvas id="canvas" width="640" height="480"></canvas>
21
+ <script src="/path/to/glslkit-webgl.js" data-entry="./app.rb"
22
+ data-ruby-version="4.0" data-wasm-wasi-version="2.10.1"></script>
23
+ ```
24
+
25
+ The shim only starts ruby.wasm. `JS::RequireRemote` fetches Ruby files and all
26
+ rendering logic remains in Ruby. `window.glslkitWebGLReady` settles when the entry
27
+ file finishes loading. Packaged wasm builds should install both gems normally.
28
+
29
+ ## Runtime API
30
+
31
+ ```ruby
32
+ require "glslkit/webgl"
33
+
34
+ ctx = Glslkit::WebGL.context("#canvas")
35
+ manifest = Glslkit::Manifest.parse(MANIFEST_JSON)
36
+ program = ctx.program(manifest, "main", vertex: VERTEX_SOURCE,
37
+ fragment: FRAGMENT_SOURCE,
38
+ source_maps: {vertex: vertex_map, fragment: fragment_map})
39
+ geometry = ctx.geometry(program: program, attributes: {
40
+ a_position: {data: positions, components: 3},
41
+ a_uv: {data: uvs, components: 2}
42
+ }, indices: indices)
43
+ texture = ctx.texture2d(width: 2, height: 2, data: rgba_pixels, unit: 0)
44
+
45
+ matrix = Array.new(16, 0.0)
46
+ ctx.loop do |seconds|
47
+ Glslkit::WebGL::Matrix.rotation_z!(matrix, seconds)
48
+ program.set(:u_transform, matrix)
49
+ program.set(:u_texture, texture.unit)
50
+ texture.bind
51
+ ctx.draw(geometry)
52
+ end
53
+ ```
54
+
55
+ Unknown uniforms and incorrect `element_count` values raise immediately.
56
+ Locations optimized away by GLSL are silently ignored. Geometry data is uploaded
57
+ once with typed-array `from`; there is intentionally no mutation API.
58
+
59
+ ## Shader errors and source maps
60
+
61
+ Pass each stage's `Glslkit::SourceMap` through `source_maps:`. Recognized driver
62
+ line numbers are resolved to the original included file. `CompileError` exposes
63
+ `stage`, `file`, `line`, and `raw_log`. Unknown log formats still raise, with
64
+ `file` and `line` set to `nil`. Link failures raise `LinkError`. JS log values are
65
+ converted with `#to_s` before parsing.
66
+
67
+ ## Debug mode
68
+
69
+ Set `Glslkit::WebGL.debug = true` to call `getError` once at each frame end. It
70
+ defaults to `false` and should not remain enabled because `getError` synchronizes
71
+ the WebGL command queue.
72
+
73
+ ## Performance and scope limits
74
+
75
+ The following CPU-side workloads are unsupported in v0.1:
76
+
77
+ - replacing vertex data every frame;
78
+ - CPU skinning;
79
+ - CPU morph-target interpolation;
80
+ - CPU-updated particle geometry.
81
+
82
+ Transferring 100,000 floats took 18.8 ms in the design measurements, exceeding a
83
+ 60 fps frame budget. Keep geometry static, deform vertices in shaders, and send
84
+ only matrices and weights through uniforms. Per-frame bone-matrix calculation
85
+ cost remains unmeasured.
86
+
87
+ The runtime is WebGL2-only. It has no WebGL1 fallback, scene graph, material
88
+ system, or glTF/VRM loader.
89
+
90
+ ## JS value safety (R6)
91
+
92
+ Never call `#class` on a value that may be a `JS::Object`; JavaScript primitives
93
+ can fail in `Reflect.has`. Use `is_a?(JS::Object)` for checks and `#inspect`,
94
+ `#typeof`, or `#to_s` for display. A test scans implementation Ruby files for
95
+ accidental `.class` calls.
96
+
97
+ Normal CRuby fails immediately with a clear message because ruby.wasm's `js`
98
+ library is required.
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/glslkit/webgl/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "glslkit-webgl"
7
+ spec.version = Glslkit::WebGL::VERSION
8
+ spec.authors = ["kyubey1228"]
9
+ spec.email = ["kyuuka1228@gmail.com"]
10
+
11
+ spec.summary = "ruby.wasm WebGL2 binding that consumes glslkit reflection manifests"
12
+ spec.description = "Lets you write WebGL2 rendering code entirely in Ruby, running under " \
13
+ "ruby.wasm in the browser. Resolves uniform/attribute locations from a glslkit " \
14
+ "reflection manifest instead of calling getActiveUniform, and surfaces shader " \
15
+ "compile errors as Ruby exceptions pointing at the original file and line. " \
16
+ "REQUIRES a ruby.wasm runtime (the `js` gem) to run — it installs under a normal " \
17
+ "CRuby/JRuby/TruffleRuby, but raises a clear LoadError instead of working there. " \
18
+ "This gem is meant to be bundled into a ruby.wasm build (see README), not required " \
19
+ "from a regular Ruby process."
20
+ spec.homepage = "https://github.com/kyubey1228/glslkit"
21
+ spec.license = "MIT"
22
+ spec.required_ruby_version = ">= 3.1"
23
+
24
+ spec.metadata["homepage_uri"] = spec.homepage
25
+ spec.metadata["source_code_uri"] = "#{spec.homepage}/tree/main/webgl"
26
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/webgl/CHANGELOG.md"
27
+
28
+ spec.files = Dir.glob("lib/**/*.rb") + Dir.glob("shim/**/*.js") + Dir.glob("sample/**/*") +
29
+ ["glslkit-webgl.gemspec", "README.md", "CHANGELOG.md", "LICENSE.txt"]
30
+ spec.require_paths = ["lib"]
31
+
32
+ # M12c: `~> 0.1.0` は `Gem::Requirement` の仕様上プレリリース版を一切
33
+ # 満たさない(`"~> 0.1.0".satisfied_by?("0.1.0.pre")` は常にfalse)。
34
+ # 0.1.0.pre のリリース期間中だけこの範囲にし、正式な0.1.0を切ったら
35
+ # `~> 0.1.0` に戻すこと。
36
+ spec.add_dependency "glslkit", ">= 0.1.0.pre", "< 0.2.0"
37
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glslkit
4
+ module WebGL
5
+ class Context
6
+ def self.from_selector(selector)
7
+ document = JS.global[:document]
8
+ canvas = document.call(:querySelector, selector)
9
+ if canvas == JS::Null || canvas == JS::Undefined
10
+ raise UnsupportedError, "canvas not found: #{selector}"
11
+ end
12
+ gl = canvas.call(:getContext, "webgl2")
13
+ if gl == JS::Null || gl == JS::Undefined
14
+ raise UnsupportedError, "WebGL2 is not available for #{selector}"
15
+ end
16
+ new(gl, canvas)
17
+ end
18
+
19
+ # 手書きマニフェスト(M10a前調査5)はglslkitの検証を一切通らずに
20
+ # ここまで来られてしまう。M10eでそのことに一度だけ気付けるようにする。
21
+ # 例外にはしない(手書きマニフェストそのものを禁止する話ではない)。
22
+ # プロセス内で1回だけ出す — Context.debugとは独立の、常時の警告。
23
+ def self.warn_missing_generator_once
24
+ return if @warned_missing_generator
25
+
26
+ @warned_missing_generator = true
27
+ warn "manifest has no generator field; hand-written manifests bypass " \
28
+ "glslkit's validation. Use `rake glslkit:embed`."
29
+ end
30
+
31
+ def initialize(gl, canvas)
32
+ @gl = gl
33
+ @canvas = canvas
34
+ @performance = JS.global[:performance]
35
+ @request_animation_frame = JS.global
36
+ @state = {program: nil}
37
+ # M11d(reload_program、SPEC-livereload.md §4)専用の追跡状態。
38
+ # development専用の機能であり、リークは許容する
39
+ # (production相当の環境ではlive_reloadをrequireせず、ここが増え
40
+ # 続ける経路自体を使わないため実害が無い。§4.1コメント参照)。
41
+ @programs = {} # name(String) => Program
42
+ @program_names = {}.compare_by_identity # Program => name(String)
43
+ @geometries = Hash.new { |h, k| h[k] = [] } # name(String) => [Geometry]
44
+ end
45
+
46
+ def width
47
+ @canvas[:width].to_i
48
+ end
49
+
50
+ def height
51
+ @canvas[:height].to_i
52
+ end
53
+
54
+ def program(manifest, name, vertex:, fragment:, source_maps: {})
55
+ manifest_hash = manifest.respond_to?(:to_h) ? manifest.to_h : manifest
56
+ self.class.warn_missing_generator_once unless manifest_hash.key?("generator")
57
+ entry = manifest_hash.fetch("programs").fetch(name.to_s) do
58
+ raise UnknownProgramError, "program not found in manifest: #{name}"
59
+ end
60
+ normalized_maps = source_maps.each_with_object({}) do |(stage, map), maps|
61
+ maps[stage.to_sym] = map.is_a?(Hash) ? Glslkit::SourceMap.from_h(map) : map
62
+ end
63
+ created = Program.new(@gl, entry,
64
+ vertex: vertex, fragment: fragment, source_maps: normalized_maps, state: @state)
65
+ register_program(name.to_s, created)
66
+ @current_program = created
67
+ end
68
+
69
+ # 名前で追跡しているProgramを取り出す(ctx.program/ctx.reload_programが
70
+ # 登録したもの)。M11e: reload_program自体はProgramオブジェクトを
71
+ # 返さない(ReloadResultを返す)ため、live_reloadを使う場合はreload後も
72
+ # 「その名前の現在のProgram」をこれで引き直さないと、毎フレームの
73
+ # uniform設定(program.set(:u_time, ...)等)が古い(差し替え済みで
74
+ # 二度と使われない)Programオブジェクトに向いたままになってしまう。
75
+ # 追跡されていない名前ならnil。
76
+ def program_for(name)
77
+ @programs[name.to_s]
78
+ end
79
+
80
+ def geometry(attributes:, indices: nil, program: nil, mode: nil)
81
+ target = program || @current_program
82
+ raise UnknownProgramError, "create a program before creating geometry" unless target
83
+
84
+ created = Geometry.new(@gl, target, attributes: attributes, indices: indices, mode: mode)
85
+ program_name = @program_names[target]
86
+ @geometries[program_name] << created if program_name
87
+ created
88
+ end
89
+
90
+ # M11d: Program単体ではなくContextの責務(SPEC-livereload.md 決定2)。
91
+ # 新しいシェーダのコンパイル・リンクが両方成功して初めて内部の
92
+ # ハンドルを差し替える。失敗時は古いProgramのまま
93
+ # CompileError/LinkErrorをそのまま投げる(§4.1)。
94
+ #
95
+ # attribute locationが新旧で変わっていた場合は、既存のVAOをそのまま
96
+ # 使うと壊れた描画になるため、ReloadIncompatibleErrorを投げて
97
+ # ページのリロードを促す(§4.2)。無言で壊れた描画を続けない。
98
+ #
99
+ # uniform値は名前ベースで引き継ぐ(§4.3)。名前・type・element_countが
100
+ # すべて一致するものだけ復元し、それ以外は破棄してdiagnosticsに
101
+ # 載せる(例外にはしない)。戻り値のResult#ok?はこの診断だけを見る —
102
+ # コンパイル/リンク/location不一致は例外なので、ここまで来た時点で
103
+ # 差し替え自体は成功している。
104
+ def reload_program(name, vertex:, fragment:, manifest:, source_maps: {})
105
+ key = name.to_s
106
+ old_program = @programs.fetch(key) do
107
+ raise UnknownProgramError, "no such program to reload (never created via Context#program): #{name}"
108
+ end
109
+
110
+ manifest_hash = manifest.respond_to?(:to_h) ? manifest.to_h : manifest
111
+ entry = manifest_hash.fetch("programs").fetch(key) do
112
+ raise UnknownProgramError, "program not found in manifest: #{name}"
113
+ end
114
+ normalized_maps = source_maps.each_with_object({}) do |(stage, map), maps|
115
+ maps[stage.to_sym] = map.is_a?(Hash) ? Glslkit::SourceMap.from_h(map) : map
116
+ end
117
+
118
+ new_program = Program.new(@gl, entry,
119
+ vertex: vertex, fragment: fragment, source_maps: normalized_maps, state: @state)
120
+
121
+ check_geometry_compatibility!(key, new_program)
122
+
123
+ discarded = new_program.restore_uniforms_from(old_program)
124
+ diagnostics = discarded.map { |info| uniform_discard_diagnostic(key, info) }
125
+
126
+ @programs[key] = new_program
127
+ @program_names.delete(old_program)
128
+ @program_names[new_program] = key
129
+ @current_program = new_program if @current_program.equal?(old_program)
130
+
131
+ ReloadResult.new(diagnostics: diagnostics)
132
+ end
133
+
134
+ def texture2d(width:, height:, data:, unit: 0)
135
+ Texture.new(@gl, width: width, height: height, data: data, unit: unit)
136
+ end
137
+
138
+ def viewport(width = nil, height = nil, x: 0, y: 0)
139
+ @gl.call(:viewport, x, y, width || self.width, height || self.height)
140
+ self
141
+ end
142
+
143
+ def depth_test=(enabled)
144
+ @gl.call(enabled ? :enable : :disable, @gl[:DEPTH_TEST])
145
+ end
146
+
147
+ def clear(red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0, depth: true)
148
+ @gl.call(:clearColor, red, green, blue, alpha)
149
+ mask = @gl[:COLOR_BUFFER_BIT].to_i
150
+ mask |= @gl[:DEPTH_BUFFER_BIT].to_i if depth
151
+ @gl.call(:clear, mask)
152
+ self
153
+ end
154
+
155
+ def draw(geometry, program: nil)
156
+ target = program || @current_program
157
+ raise UnknownProgramError, "no program selected" unless target
158
+
159
+ target.use
160
+ @gl.call(:bindVertexArray, geometry.vao)
161
+ if geometry.indexed
162
+ @gl.call(:drawElements, geometry.mode, geometry.count, geometry.index_type, 0)
163
+ else
164
+ @gl.call(:drawArrays, geometry.mode, 0, geometry.count)
165
+ end
166
+ self
167
+ end
168
+
169
+ def loop(&block)
170
+ raise ArgumentError, "block required" unless block
171
+
172
+ start = @performance.call(:now).to_f
173
+ tick = nil
174
+ tick = proc do |timestamp|
175
+ block.call((timestamp.to_f - start) / 1000.0)
176
+ check_error if Glslkit::WebGL.debug
177
+ @request_animation_frame.call(:requestAnimationFrame, tick)
178
+ end
179
+ @request_animation_frame.call(:requestAnimationFrame, tick)
180
+ tick
181
+ end
182
+
183
+ private
184
+
185
+ def register_program(name, created)
186
+ @programs[name] = created
187
+ @program_names[created] = name
188
+ end
189
+
190
+ # このプログラム名で作られた全Geometryについて、構築時に使った
191
+ # attribute locationが新Programでも変わっていないかを確認する。
192
+ # 1つでも変わっていればReloadIncompatibleError。
193
+ def check_geometry_compatibility!(key, new_program)
194
+ @geometries[key].each do |geometry|
195
+ geometry.attribute_locations.each do |attribute_name, old_location|
196
+ new_location = new_program.attribute_locations[attribute_name]
197
+ next if new_location == old_location
198
+
199
+ raise ReloadIncompatibleError,
200
+ "attribute #{attribute_name} location changed (#{old_location.inspect} -> " \
201
+ "#{new_location.inspect}) while reloading #{key}; reload the page"
202
+ end
203
+ end
204
+ end
205
+
206
+ def uniform_discard_diagnostic(program_name, discarded)
207
+ message = case discarded.fetch(:reason)
208
+ when :removed
209
+ "uniform #{discarded.fetch(:name)} no longer exists in the reloaded shader; discarding its previous value"
210
+ when :type_changed
211
+ "uniform #{discarded.fetch(:name)} changed type (#{discarded.fetch(:from)} -> " \
212
+ "#{discarded.fetch(:to)}); discarding its previous value"
213
+ end
214
+ Glslkit::Diagnostic.new(
215
+ severity: :warning, code: "W101", message: message,
216
+ program: program_name, stage: nil, name: discarded.fetch(:name).to_s, file: nil, line: nil
217
+ )
218
+ end
219
+
220
+ def check_error
221
+ error = @gl.call(:getError).to_i
222
+ return if error == @gl[:NO_ERROR].to_i
223
+
224
+ raise Error, "WebGL error: #{error}"
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ # 開発機(通常のCRuby)で実行するホスト側の生成ツール(M10a)。
4
+ # File/Dir、Preprocessor/Reflection/Validator/digestなどcoreのフル機能を使う。
5
+ # **`glslkit/webgl`(ブラウザ側ランタイムの入口)からは絶対にrequireしないこと。
6
+ # ruby.wasm上では実行しない。** `rake glslkit:embed` からのみ使う想定。
7
+ require "glslkit"
8
+ require "pp"
9
+ require "fileutils"
10
+
11
+ module Glslkit
12
+ module WebGL
13
+ module Embed
14
+ # 冪等性のための固定値(実際の生成時刻ではない)。generated_atが実行の
15
+ # たびに変わるとgit diffがノイズになるため、実時刻の代わりにこれを使う。
16
+ EMBEDDED_TIMESTAMP = Time.at(0).utc
17
+
18
+ class ValidationError < StandardError; end
19
+ class InvalidProgramNameError < StandardError; end
20
+
21
+ # 有効なRubyの定数名(1つの識別子として)であることを要求する。
22
+ MODULE_NAME_PATTERN = /\A[A-Z][A-Za-z0-9_]*\z/
23
+
24
+ module_function
25
+
26
+ # shaders_dir配下の.vert/.fragペアごとに1つの.rbファイルをout_dirへ書き出す。
27
+ # 戻り値は [{name:, path:, warnings: [Glslkit::Diagnostic]}, ...]。
28
+ # Validatorでエラーがあれば書き出さず ValidationError を投げる
29
+ # (SPEC.md §8.6: Validator#validate は必ず Manifest.build より前)。
30
+ def generate(shaders_dir:, out_dir:)
31
+ root = File.expand_path(shaders_dir)
32
+ FileUtils.mkdir_p(out_dir)
33
+ resolver = Glslkit::Resolvers::FileSystem.new(load_paths: [root])
34
+
35
+ program_stage_paths(root).sort.map do |name, stages|
36
+ write_program(resolver, name, stages, out_dir)
37
+ end
38
+ end
39
+
40
+ # 実体(前処理→Program化→検証→Manifest組み立て)は Glslkit::Bundle が
41
+ # 持つ(M11b)。ここは Bundle の結果を見て、生成物として書き出すか
42
+ # 例外を投げるかを判断するだけの薄いラッパー。
43
+ def write_program(resolver, name, stages, out_dir)
44
+ module_name_for(name) # 実際にBundleを呼ぶ前に名前だけ先に検証する(fail fast)。
45
+ result = Glslkit::Bundle.build(
46
+ resolver: resolver, name: name, vertex: stages.fetch(:vertex), fragment: stages.fetch(:fragment),
47
+ line_directives: false, now: EMBEDDED_TIMESTAMP
48
+ )
49
+
50
+ case result.kind
51
+ when :preprocess
52
+ raise result.error
53
+ when :validation
54
+ errors = result.diagnostics.select(&:error?)
55
+ raise ValidationError, "glslkit:embed validation failed for #{name}:\n#{errors.join("\n")}"
56
+ end
57
+
58
+ # 警告は生成を止めない(エラーと違う)が、握り潰さず必ず標準エラーに出す。
59
+ # rakeタスク経由でなく Embed.generate を直接呼んだ場合でも見えるように、
60
+ # ここ(ライブラリ側)で出す。呼び出し側での二重出力は行わない。
61
+ warnings = result.diagnostics.select(&:warning?)
62
+ warnings.each { |warning| warn "[glslkit:embed] #{name}: #{warning}" }
63
+
64
+ out_path = File.join(out_dir, "#{name}_shaders.rb")
65
+ File.write(out_path, render(name: name, manifest: result.manifest, vertex: result.vertex, fragment: result.fragment))
66
+ {name: name, path: out_path, warnings: warnings}
67
+ end
68
+
69
+ # {"hello" => {vertex: "hello.vert", fragment: "hello.frag"}, ...} を返す。
70
+ # 同名の相方が無い.vert/.fragはスキップする(rails側のManifestBuilderと同じ扱い)。
71
+ def program_stage_paths(root)
72
+ by_name = Hash.new { |h, k| h[k] = {} }
73
+ Dir.glob("**/*.{vert,frag}", base: root).each do |relative|
74
+ ext = File.extname(relative)
75
+ stage = (ext == ".vert") ? :vertex : :fragment
76
+ by_name[relative.delete_suffix(ext)][stage] = relative
77
+ end
78
+ by_name.select { |_, stages| stages.key?(:vertex) && stages.key?(:fragment) }
79
+ end
80
+
81
+ def render(name:, manifest:, vertex:, fragment:)
82
+ module_name = module_name_for(name)
83
+ vertex_tag = unique_tag(vertex.code, "GLSLKIT_VERTEX_SRC")
84
+ fragment_tag = unique_tag(fragment.code, "GLSLKIT_FRAGMENT_SRC")
85
+
86
+ <<~RUBY
87
+ # frozen_string_literal: true
88
+
89
+ # 自動生成 (rake glslkit:embed)。手で編集しない。
90
+ # 再生成するには: bundle exec rake glslkit:embed[shaders_dir,out_dir]
91
+ #
92
+ # MANIFESTのgenerated_atが1970-01-01なのはバグではない。生成の
93
+ # 冪等性(同じ入力から同じ出力になること)のための固定値であり、
94
+ # 実行時刻を使うと同じ入力でも実行のたびにgit diffが出てしまう。
95
+ # 再現性はstages.*.digest(実データから計算される)が担保している。
96
+
97
+ module #{module_name}
98
+ VERTEX = #{heredoc_literal(vertex.code, vertex_tag, suffix: ".freeze")}
99
+
100
+ FRAGMENT = #{heredoc_literal(fragment.code, fragment_tag, suffix: ".freeze")}
101
+
102
+ MANIFEST = #{pp_literal(manifest)}.freeze
103
+
104
+ SOURCE_MAPS = {
105
+ vertex: #{pp_literal(vertex.source_map.to_h)},
106
+ fragment: #{pp_literal(fragment.source_map.to_h)}
107
+ }.freeze
108
+ end
109
+ RUBY
110
+ end
111
+
112
+ # 非squiggly(`<<~`ではなく`<<`)のシングルクォートヒアドキュメントを使う。
113
+ # `<<~`は最小共通インデントを削るため、code側の行のインデントが
114
+ # 想定と揃っていないとsource.codeとバイト一致しなくなる
115
+ # (例: 全行が2スペース下がっているとsource.codeの2スペースが
116
+ # 削られてしまう)。`<<`は一切ディデントしないため常にバイト一致する。
117
+ def heredoc_literal(code, tag, suffix: "")
118
+ "<<'#{tag}'#{suffix}\n#{code.chomp}\n#{tag}"
119
+ end
120
+
121
+ # codeの中に偶然tagと同じ行が現れても壊れないよう、衝突する限りtagをずらす。
122
+ # 冪等性のため決定的な連番だけを使う(object_idやSecureRandom等は不可)。
123
+ def unique_tag(code, base)
124
+ tag = base
125
+ suffix = 2
126
+ while code.lines.any? { |line| line.strip == tag }
127
+ tag = "#{base}_#{suffix}"
128
+ suffix += 1
129
+ end
130
+ tag
131
+ end
132
+
133
+ def pp_literal(value)
134
+ PP.pp(value, +"", 80).chomp
135
+ end
136
+
137
+ # 規則(修正4): プログラム名を非英数字(`_` `-` `.` 等、任意の連続)で
138
+ # 単語に分割し、各単語の先頭だけ大文字化して連結、末尾に "Shaders" を
139
+ # 付ける。例: "neon" -> "NeonShaders"、"pbr_lighting" -> "PbrLightingShaders"、
140
+ # "common.glsl" -> "CommonGlslShaders"(`.`も区切り文字として扱う)。
141
+ #
142
+ # 結果が有効なRubyの定数名でなければ(例: "2d-post" -> "2dPostShaders" は
143
+ # 数字始まりで無効)、無言で辻褄を合わせようとせず例外を投げる。
144
+ def module_name_for(name)
145
+ words = name.to_s.split(/[^a-zA-Z0-9]+/).reject(&:empty?)
146
+ camelized = words.map { |word| word[0].upcase + word[1..] }.join
147
+ module_name = "#{camelized}Shaders"
148
+ unless MODULE_NAME_PATTERN.match?(module_name)
149
+ raise InvalidProgramNameError,
150
+ "cannot derive a valid Ruby module name from program #{name.inspect} " \
151
+ "(got #{module_name.inspect}). Rename the .vert/.frag pair so the shared " \
152
+ "basename starts with a letter."
153
+ end
154
+ module_name
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glslkit
4
+ module WebGL
5
+ class Error < StandardError; end
6
+ class UnsupportedError < Error; end
7
+ class UnknownProgramError < Error; end
8
+ class UnknownUniformError < Error; end
9
+ class UniformLengthError < Error; end
10
+ class ShaderError < Error; end
11
+ # M11d: reload_program(SPEC-livereload.md §4.2)で新旧のattribute location
12
+ # が一致しない場合に投げる。既存のVAOをそのまま使い続けると壊れた描画に
13
+ # なるため、無言で続行せずページのリロードを促す。
14
+ class ReloadIncompatibleError < Error; end
15
+
16
+ class CompileError < ShaderError
17
+ attr_reader :stage, :file, :line, :raw_log
18
+
19
+ def initialize(stage:, raw_log:, file: nil, line: nil, detail: nil)
20
+ @stage = stage
21
+ @file = file
22
+ @line = line
23
+ @raw_log = raw_log.to_s
24
+ message = detail || @raw_log
25
+ message = "#{file}:#{line}: #{message}" if file && line
26
+ super(message)
27
+ end
28
+ end
29
+
30
+ class LinkError < ShaderError
31
+ attr_reader :raw_log
32
+
33
+ def initialize(raw_log)
34
+ @raw_log = raw_log.to_s
35
+ super(@raw_log)
36
+ end
37
+ end
38
+ end
39
+ end