fury-js
Version:
WebGL Game Engine
507 lines (453 loc) • 20 kB
JavaScript
const r = require('./renderer');
const IndexedMap = require('./indexedMap');
const Material = require('./material');
const Mesh = require('./mesh');
const Prefab = require('./prefab');
const Transform = require('./transform');
const Maths = require('./maths');
const Bounds = require('./bounds');
const { mat3, mat4, quat, vec3 } = Maths;
module.exports = (function() {
let nextSceneId = 0;
let exports = {};
// Note Meshes and Materials shared across scenes
// Going to use dictionaries but with an array of keys for enumeration (hence private with accessor methods)
let meshes = IndexedMap.create();
let materials = IndexedMap.create();
let shaders = IndexedMap.create();
let textures = IndexedMap.create();
// Note: clears all resources - any uncleared existing scenes will break
exports.clearResources = function() {
meshes.clear();
materials.clear();
shaders.clear();
textures.clear();
};
// TODO: Add clearUnusedResources - which enumerates through scene renderObjects / prefab instances
// to check objects are used or reference count them - will need to track created scenes
// glState Tracking - shared across scenes
let currentShaderId, currentMaterialId, currentMeshId;
let nextTextureLocation = 0, currentTextureBindings = {}, currentTextureLocations = []; // keyed on texture.id to binding location, keyed on binding location to texture.id
let bindMaterialTextureLocations = function(material) {
for (let i = 0, l = material.shader.textureUniformNames.length; i < l; i++) {
let uniformName = material.shader.textureUniformNames[i];
let texture = material.textures[uniformName];
if (texture) {
textures.add(texture);
bindTextureToLocation(texture);
}
}
};
let bindTextureToLocation = function(texture) {
if (currentTextureBindings[texture.id] === undefined) {
if (currentTextureLocations.length < r.TextureLocations.length) {
r.setTexture(currentTextureLocations.length, texture);
currentTextureBindings[texture.id] = currentTextureLocations.length;
currentTextureLocations.push(texture.id);
} else {
// replace an existing texture
delete currentTextureBindings[currentTextureLocations[nextTextureLocation]];
r.setTexture(nextTextureLocation, texture);
currentTextureBindings[texture.id] = nextTextureLocation;
currentTextureLocations[nextTextureLocation] = texture.id;
nextTextureLocation = (nextTextureLocation+1) % r.TextureLocations.length;
}
}
};
exports.create = function({ camera, enableFrustumCulling, forceSphereCulling }) {
let cameras = {};
let cameraNames = [];
let mainCameraName = "main";
// mvMatrix may need to be a stack in future (although a stack which avoids unnecessary mat4.creates)
let pMatrix = mat4.create(), mvMatrix = mat4.create(), nMatrix = mat3.create(), cameraMatrix = mat4.create(), cameraOffset = vec3.create(), inverseCameraRotation = quat.create();
let pMatrixRebound = false, vMatrixRebound = false;
let scene = {};
scene.id = (nextSceneId++).toString();
scene.enableFrustumCulling = !!enableFrustumCulling;
// these renderObjects / instances on prefabs need to contain at minimum materialId, meshId, and transform (currently object just has material and mesh as well as transform)
let renderObjects = IndexedMap.create(); // TODO: use materialId / meshId to bind
let prefabs = { keys: [] }; // Arguably instances could be added to renderer objects and memory would still be saved, however keeping a separate list allows easier batching for now
// TODO: Should have an equivilent to indexedMap but where you supply the keys, keyedMap?.
let alphaRenderObjects = [];
let depths = {};
depths.get = (o) => {
let id = o.sceneId !== undefined ? o.sceneId : o.id;
return depths[id];
};
depths.set = (o, depth) => {
let id = o.sceneId !== undefined ? o.sceneId : o.id;
depths[id] = depth;
};
let addToAlphaList = function(object, depth) {
// TODO: Profile using Array sort instead of insertion sorting, also test add/remove from list rather than clear
depths.set(object, depth);
// Binary search
// Could technically do better by batching up items with the same depth according to material / mesh like scene graph
// However this is only relevant for 2D games with orthographic projection
let less, more, itteration = 1, inserted = false, index = Math.floor(alphaRenderObjects.length/2);
while (!inserted) {
less = (index === 0 || depths.get(alphaRenderObjects[index-1]) <= depth);
more = (index >= alphaRenderObjects.length || depths.get(alphaRenderObjects[index]) >= depth);
if (less && more) {
alphaRenderObjects.splice(index, 0, object);
inserted = true;
} else {
itteration++;
var step = Math.ceil(alphaRenderObjects.length/(2*itteration));
if(!less) {
index = Math.max(0, index - step);
} else {
index = Math.min(alphaRenderObjects.length, index + step);
}
}
}
};
let createObjectBounds = function(object, mesh, rotation) {
// If object is static and not rotated, create object AABB from mesh bounds
if (!forceSphereCulling && object.static && (!rotation || Maths.quat.isIdentity(rotation))) {
// TODO: Allow for calculation of AABB of rotated meshes
let center = vec3.clone(mesh.bounds.center);
vec3.add(center, center, object.transform.position);
let size = vec3.clone(mesh.bounds.size);
object.bounds = Bounds.create({ center: center, size: size });
}
};
let isCulledByFrustrum = function(camera, object) {
if (!object.static || !object.bounds) {
return !camera.isSphereInFrustum(object.transform.position, object.mesh.boundingRadius);
} else {
return !camera.isInFrustum(object.bounds);
}
};
let sortByMaterial = function(a, b) {
if (a.materialId == b.materialId) {
return 0;
} else if (a.materialId < b.materialId) { // Note: will not order strings by their parsed numberical value, however this is not required.
return +1;
} else {
return -1;
}
};
// Add Render Object
scene.add = function(config) {
let object = {};
let { mesh, material, static = false, active = true } = config;
object.material = material;
object.mesh = mesh;
if (!mesh || !material) {
throw new Error("Mesh and Material must be present on the object.");
}
// Note: indexedMap.add adds id property to object added and does not add duplicates
object.meshId = meshes.add(object.mesh);
object.materialId = materials.add(object.material);
object.shaderId = shaders.add(object.material.shader);
object.material.shaderId = object.shaderId;
bindMaterialTextureLocations(object.material);
if (config.transform) {
object.transform = config.transform;
} else {
object.transform = Transform.create(config);
}
// For now just sort by material on add
// Ideally would group materials with the same shader and textures together
object.sceneId = renderObjects.add(object, sortByMaterial);
// Would probably be more performant to dynamic changes if kept a record of start and end index
// of all materials and could simply inject at the correct point - TODO: Profile
object.static = static;
object.active = active;
createObjectBounds(object, object.mesh, object.transform.rotation);
return object;
};
scene.remove = function(object) {
// Note: This does not free up the resources (e.g. mesh and material references remain) in the scene, may need to reference count these and delete
if (object.sceneId !== undefined) {
renderObjects.remove(object.sceneId);
} else if (object.id) {
// Is prefab, look on prototype for instances and remove this
object.instances.remove(object.id);
// Note not deleting the locally stored prefab, even if !instances.length as we would get duplicate mesh / materials if we were to readd
// Keeping the prefab details around is preferable and should be low overhead
}
};
scene.clear = function() {
// Note: This does not free up the resources (e.g. mesh and material references remain) in the scene, may need to reference count these and delete
renderObjects.clear();
alphaRenderObjects.length = 0;
if (prefabs.keys.length) {
// Recreate prefab object - i.e. remove all prefabs and instances in one swoop.
prefabs = { keys: [] };
}
};
// Instantiate prefab instance
scene.instantiate = function(config) {
let prefab;
if (!config || !config.name || !Prefab.prefabs[config.name]) {
throw new Error("You must provide a valid prefab name");
}
let name = config.name;
if (!prefabs[name]) {
let defn = Prefab.prefabs[name];
if (!defn.materialConfig || !defn.meshConfig) {
throw new Error("Requested prefab must have a material and a mesh config present");
}
prefab = {
active: true,
name: name,
instances: IndexedMap.create(),
mesh: Mesh.create(defn.meshConfig),
material: Material.create(defn.materialConfig)
};
prefab.meshId = meshes.add(prefab.mesh);
prefab.materialId = materials.add(prefab.material);
prefab.shaderId = shaders.add(prefab.material.shader);
prefab.material.shaderId = prefab.shaderId;
bindMaterialTextureLocations(prefab.material);
prefabs[name] = prefab;
prefabs.keys.push(name);
} else {
prefab = prefabs[name];
}
let instance = Object.create(prefab);
instance.transform = Transform.create(config);
let { static = false, active = true } = config;
instance.id = prefab.instances.add(instance);
instance.static = static;
instance.active = active;
createObjectBounds(instance, prefab.mesh, instance.transform.rotation);
return instance;
};
scene.setPrefabActive = function(name, active) {
if (prefabs[name]) {
prefabs[name].active = active;
} else {
console.warn("Unable to find prefab '" + name + "' to set active " + active);
}
};
// Add Camera
// Arguably camera.render(scene) would be a preferable pattern
scene.addCamera = function(camera, name) {
let key = name ? name : "main";
if (cameraNames.length === 0) {
mainCameraName = key;
}
if (!cameras[key]) {
cameraNames.push(key);
}
cameras[key] = camera;
};
// Render
scene.render = function(cameraName) {
let camera = cameras[cameraName ? cameraName : mainCameraName];
if (scene.enableFrustumCulling) {
camera.calculateFrustum();
}
camera.getProjectionMatrix(pMatrix);
// Camera Matrix should transform world space -> camera space
quat.invert(inverseCameraRotation, camera.rotation); // camera looks in negative Z direction
mat4.fromQuat(cameraMatrix, inverseCameraRotation);
mat4.translate(cameraMatrix, cameraMatrix, vec3.negate(cameraOffset, camera.position));
pMatrixRebound = false;
vMatrixRebound = false;
alphaRenderObjects.length = 0;
// TODO: Scene Graph
// Batched first by Shader
// Then by Material
// Then by Mesh
// Then render each Mesh Instance
// An extension would be to batch materials such that shaders that textures used overlap
// This batching by shader / material / mesh may need to be combined with scene management techniques
if (camera.clear) {
if (camera.clearColor) {
r.clearColor(camera.clearColor[0], camera.clearColor[1], camera.clearColor[2], camera.clearColor[3]);
}
r.clear();
} else if (camera.clearDepth) {
r.clearDepth();
}
// TODO: Scene graph should provide these as a single thing to loop over, will then only split and loop for instances at mvMatrix binding / drawing
// Scene Graph should be class with enumerate() method, that way it can batch as described above and sort watch its batching / visibility whilst providing a way to simple loop over all elements
let culled = false, renderObject = null;
for (let i = 0, l = renderObjects.keys.length; i < l; i++) {
// TODO: Detect if resorting is necessary (check +1 and -1 in array against sort function)
renderObject = renderObjects[renderObjects.keys[i]];
if (scene.enableFrustumCulling && renderObject.active) {
culled = isCulledByFrustrum(camera, renderObject);
}
if (!culled && renderObject.active) {
if(renderObject.material.alpha) {
let sortPosition = renderObject.transform.position
if (renderObject.bounds) {
sortPosition = renderObject.bounds.center;
}
addToAlphaList(renderObject, camera.getDepth(sortPosition));
} else {
bindAndDraw(renderObject);
}
}
}
for (let i = 0, l = prefabs.keys.length; i < l; i++) {
let prefab = prefabs[prefabs.keys[i]];
if (prefab.active) {
let instances = prefab.instances;
for (let j = 0, n = instances.keys.length; j < n; j++) {
let instance = instances[instances.keys[j]];
if (scene.enableFrustumCulling && instance.active) {
culled = isCulledByFrustrum(camera, instance);
}
if (!culled && instance.active) {
if (instance.material.alpha) {
let sortPosition = instance.transform.position;
if (instance.bounds) {
sortPosition = instance.bounds.center;
}
addToAlphaList(instance, camera.getDepth(sortPosition));
} else {
bindAndDraw(instance);
}
}
}
}
}
for (let i = 0, l = alphaRenderObjects.length; i < l; i++) {
renderObject = alphaRenderObjects[i];
let m = renderObject.material;
// Could probably do this in bind and draw method
if (!m.blendSeparate) {
r.enableBlending(m.sourceBlendType, m.destinationBlendType, m.blendEquation);
} else {
r.enableSeparateBlending(m.sourceColorBlendType, m.destinationColorBlendType, m.sourceAlphaBlendType, m.destinationAlphaBlendType, m.blendEquation);
}
bindAndDraw(renderObject);
}
r.disableBlending();
};
let bindAndDraw = function(object) {
let shader = object.material.shader;
let material = object.material;
let mesh = object.mesh;
// BUG:
// If there's only one material or one mesh in the scene real time changes to the material or mesh will not present themselves as the id will still match the currently bound
// mesh / material, seems like we're going need a flag on mesh / material for forceRebind for this case. (should probably be called forceRebind as it 'might' be rebound anyway)
// Having now determined that actually we don't need to rebind uniforms when switching shader programs, we'll need this flag whenever there's only one mesh or material using a given shader.
// TODO: When scene graph implemented - check material.shaderId & object.shaderId against shader.id, and object.materialId against material.id and object.meshId against mesh.id
// as this indicates that this object needs reordering in the graph (as it's been changed).
let shouldRebindShader = false;
let shouldRebindMaterial = false;
if (!shader.id || shader.id != currentShaderId) {
shouldRebindShader = true;
// Check if shader was changed on the material since originally added to scene
if(!shader.id) {
material.shaderId = shaders.add(shader);
object.shaderId = material.shaderId;
} else {
if (material.shaderId != shader.id) {
material.shaderId = shader.id;
}
if (object.shaderId != shader.id) {
object.shaderId = shader.id;
}
}
currentShaderId = shader.id;
r.useShaderProgram(shader.shaderProgram);
pMatrixRebound = false;
vMatrixRebound = false;
}
if (!pMatrixRebound) {
// New Shader or New Frame, rebind projection Matrix
r.setUniformMatrix4(shader.pMatrixUniformName, pMatrix);
pMatrixRebound = true;
}
if (!vMatrixRebound) {
if (shader.vMatrixUniformName) {
r.setUniformMatrix4(shader.vMatrixUniformName, cameraMatrix);
}
vMatrixRebound = true;
}
if (!material.id || material.id != currentMaterialId || material.dirty) {
if (!material.dirty) {
shouldRebindMaterial = true;
} else {
material.dirty = false;
}
// check if material was changed on object since originally
// added to scene TODO: Ideally would mark object for resorting
if (!material.id) {
object.materialId = materials.add(material);
} else if (object.materialId != material.id) {
object.materialId = material.id;
}
currentMaterialId = material.id;
shader.bindMaterial.call(r, material);
}
if (shouldRebindShader || shouldRebindMaterial) {
// Texture Rebinding dependencies
// If the shader has changed you DON'T need to rebind, you only need to rebind if the on the uniforms have changed since the shaderProgram was last used...
// NOTE Large Changes needed because of this
// I think we're just going to have to add a flag to materials and meshes to say "rebind" (because I've changed something)
// This also means we should move the "currentMeshId / currentMaterial id to the shader instead or keep a keyed list on shader the id
// Lets do this after we've done the texture binding though eh? so for now just rebind everything if shader or material changes (overkill but it'll work)
// If the material has changed textures may need rebinding
// Check for gl location rebinds needed, if any needed and rebind all to make sure we don't replace a texture we're using
let locationRebindsNeeded = false, uniformName = null, texture = null;
for (let i = 0, l = shader.textureUniformNames.length; i < l; i++) {
uniformName = shader.textureUniformNames[i];
if (material.textures[uniformName]) {
texture = material.textures[uniformName];
if (!texture.id) {
textures.add(texture);
locationRebindsNeeded = true;
break;
}
if (isNaN(currentTextureBindings[texture.id])) {
locationRebindsNeeded = true;
break;
}
}
}
// Rebind if necessary and set uniforms
for (let i = 0, l = shader.textureUniformNames.length; i < l; i++) {
uniformName = shader.textureUniformNames[i];
if (material.textures[uniformName]) {
texture = material.textures[uniformName];
if (locationRebindsNeeded) {
bindTextureToLocation(texture);
}
r.setUniformInteger(uniformName, currentTextureBindings[texture.id]);
}
}
}
if (!mesh.id || mesh.id != currentMeshId || mesh.dirty) {
// Check if mesh was changed on object since originally added to scene
if (!mesh.id) {
object.meshId = mesh.add(mesh);
} else if (object.meshId != mesh.id) {
object.meshId = mesh.id;
}
currentMeshId = mesh.id;
shader.bindBuffers.call(r, mesh);
mesh.dirty = false;
}
object.transform.updateMatrix();
if (shader.mMatrixUniformName) {
r.setUniformMatrix4(shader.mMatrixUniformName, object.transform.matrix);
}
if (shader.mvMatrixUniformName) {
mat4.multiply(mvMatrix, cameraMatrix, object.transform.matrix);
r.setUniformMatrix4(shader.mvMatrixUniformName, mvMatrix);
}
if (shader.nMatrixUniformName) {
mat3.normalFromMat4(nMatrix, mvMatrix);
r.setUniformMatrix3(shader.nMatrixUniformName, nMatrix);
}
if (shader.bindInstance) {
shader.bindInstance.call(r, object);
}
r.draw(mesh.renderMode, mesh.indexed ? mesh.indexBuffer.numItems : mesh.vertexBuffer.numItems, mesh.indexed, 0);
};
if (camera) {
scene.addCamera(camera);
}
return scene;
};
return exports;
})();