Created
July 19, 2019 22:22
-
-
Save DavidJCobb/35506f8056c3c49538edfdad71e89dcd to your computer and use it in GitHub Desktop.
ESO Experiment/Rough Draft - Building and Rendering a Heightmap
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
-- | |
-- This two-file add-on will track your position every tenth of a second | |
-- (or thirtieth when mounted, if I got the update re-registration right) | |
-- and maintain a grid of where you've been. Every fifth of a second, it | |
-- redraws the grid using Texture controls rendered in 3D as gridlines. | |
-- | |
-- ISSUES: | |
-- | |
-- - Performance (see below comments) | |
-- | |
-- - No way to tell when the player is in midair, so jumping breaks the | |
-- tracking. Made it so that when revisiting a grid point, we always | |
-- prefer the lowest seen Y-coordinate; caveat is this breaks over- | |
-- hangs a bit | |
-- | |
-- - The faster you move, the worse the problem becomes, because | |
-- actors/mounts catch air weirdly easily at speed in this game | |
-- | |
-- NOTES: | |
-- | |
-- - I uploaded this file to accompany a video of it in action, at | |
-- <https://youtu.be/RZTvQ_R9N6o>. This file is an experiment and a | |
-- first draft, using undocumented APIs that are seldom used or not | |
-- used even in Zenimax's own code. There are probably flaws and | |
-- bugs in how I manage controls, coordinate systems, and other | |
-- game-/UI-specific minutiae. Still, since you're here, hopefully | |
-- you get some use or entertainment out of it. | |
-- | |
-- - The reason Maps are separated into Planes is to allow for under- | |
-- ground passages, caves, and relatively large overhangs; idea was | |
-- to have someone who is mapping out an area switch planes by hand | |
-- somehow, and then stitch planes together automatically when | |
-- exporting the data (currently no export or saving implemented yet) | |
-- | |
-- - Based on Lib3D documentation and some ZOS API behaviors, the game | |
-- has AT LEAST three different 3D coordinate systems; the API does a | |
-- poor job of telegraphing what functions need what coordinates; we | |
-- track grid points (Coords) using whatever coordinate system is | |
-- used by GetUnitWorldPosition | |
-- | |
-- - Speaking of, we rely on Lib3D for an event handler | |
-- | |
-- - Original motivation was to be able to create detailed maps of a | |
-- zone, since the game's built-in world and zone maps are... well, | |
-- bad. The maps do a very poor job of displaying cliffs and other | |
-- impassable terrain, and if you're new to a zone or rarely visit | |
-- it, then that makes it a huge pain to reach a specific goal. (The | |
-- maps are stylized, but they sort of mimic a satellite photo? It's | |
-- like the worst of both words -- not stylized enough for cliffs to | |
-- be denoted with actual lines, but not photorealistic enough for | |
-- cliffs to be easily recognizable in and of themselves) | |
-- | |
-- Probably a better way to address this would be to use variations | |
-- on this file's basic concept, as a means of collecting data that | |
-- could then be built into a mapping add-on of some kind; ideas: | |
-- | |
-- - Press a button to toggle line-drawing; then, run along a cliff | |
-- edge or face; use this to literally outline impassable terrain | |
-- | |
-- - This'd be great for small fences, which this file's heightmap | |
-- approach can miss completely (if they're thin enough; think | |
-- of the fences between Windhelm's guild stores and crafting | |
-- hall) | |
-- | |
-- - Press a button to outline walkable areas, and then another | |
-- button to fill them in (sort of like making navmeshes with | |
-- N-gon support); overlapping areas could be automatically | |
-- merged. Could show N-gon boundaries in-world and maybe even | |
-- have controls to move/delete the last point and so on | |
-- | |
-- - Splitting N-gons into convex meshes may be a relevant | |
-- concern, especially if you need to test whether a point | |
-- or line intersects them (separating axis theorem requires | |
-- convex shapes) | |
-- | |
-- - Good way to model walkable areas quickly, with the caveat | |
-- that you won't have any height data inside of an N-gon e.g. | |
-- hills unless you allow concentric/nested N-gons | |
-- | |
-- - Failing that, do the same thing that this file does, but make | |
-- it so that whatever you're collecting the data for can fill in | |
-- the gaps implicitly whenever there isn't a large known height | |
-- difference somewhere in those gaps | |
-- | |
-- - Potential cool ideas: | |
-- | |
-- - Map out a zone (per above) and collect the data somehow; pack | |
-- it into an add-on and make a literal GPS that can show you the | |
-- fastest path to a destination both on the zone map and in 3D | |
-- | |
-- - You could also "record" special-case paths, e.g. maneuvers | |
-- needed to safely slide down cliffs that would ordinarily | |
-- kill you to death; these could be enabled for routing, with | |
-- 3D markers displayed at the edge of the cliff (think of a | |
-- giant "slow down now!" sign) and waypoints displayed on the | |
-- specific parts a player needs to land on to survive their | |
-- way down | |
-- | |
-- - Could measure water, too, and factor that into routes for | |
-- Argonians (who not only swim between walk and sprint speed, | |
-- but also retain movement speed buffs while swimming) | |
-- | |
local function sign(v) | |
return v >= 0 and 1 or -1 | |
end | |
local function round(v, mult) | |
mult = mult or 1 | |
return math.floor((v / mult) + (sign(v) * 0.5)) * mult | |
end | |
-- remember: X and Z are lateral in this game; Y is height | |
-- performance optimizations need testing; all parts are tagged with | |
-- "UNTESTED OPTIMIZATION" | |
-- goal is to reduce overhead of redrawing the grid once we have a TON of | |
-- coordinates stored | |
-- | |
-- - associate each gridline with the two points it connects, bidirection- | |
-- ally | |
-- | |
-- - when rendering the grid, for each pair of points we want to connect, | |
-- check if they already have a gridline control; if so, just update | |
-- its position | |
-- | |
-- - further optimizations may be possible by writing the height of | |
-- each point to the gridline control, i.e. you'd be able to tell | |
-- if either point actually changed and skip updating if not | |
-- | |
-- - after the grid is rendered, loop over all extant controls and | |
-- kill any that have been severed from their points or that have no | |
-- points | |
-- NOTE: i didn't test long-range travel (reportedly UI stuff recenters | |
-- or something when you move too far) due to other performance concerns | |
-- that kicked in first, so i don't know if that will cause rendering | |
-- inaccuracies; i also didn't test transitioning into another zone | |
-- here's a fun thing to try after covering an area in gridlines: | |
-- /script SetShouldRenderWorld(false) | |
-- | |
-- NOTE: rendering too large a grid WILL cause the game to malfunction, | |
-- eventually being unable to reliably draw new grid pieces even (or | |
-- especially) after a /reloadui. sadly some compromises will need to | |
-- be made; ideas: | |
-- | |
-- - fade gridlines out at a distance; cap the render distance | |
-- | |
-- - render the grid at a lower resolution past some threshold (i.e. | |
-- the current 175-unit resolution near, then 350 further out, etc.) | |
-- | |
-- - combine contiguous gridlines that have identical or nearly- | |
-- identical slopes (very good for a lot of cities) | |
-- | |
-- TODO: see if we can count the number of gridlines we can render | |
-- before the game chokes; measuring this will be complicated by the | |
-- fact that either a log-out (not sure) or a full game restart is | |
-- needed to ensure stable behavior (so if you count to 400, /reloadui | |
-- to see if things break, and they don't, then you need to restart | |
-- the game and try 600; etc.) | |
-- | |
-- TODO: save the gathered coordinates; probably best to use a binary | |
-- format; could compact X- and Z-axis values by dividing them by the | |
-- Map resolution | |
local Coord = {} | |
do | |
Coord.meta = { __index = Coord } | |
function Coord:new(x, y, z, lateralDistance, properties) | |
local result = setmetatable({}, self.meta) | |
result.x = x | |
result.y = y | |
result.z = z | |
result.gridlines = {} | |
result.lateralDistance = lateralDistance or 0 | |
-- | |
local props = properties or {} | |
result.isWater = props.isWater or false | |
-- | |
return result | |
end | |
function Coord:distanceTo(other) | |
local x = (other.x - self.x) | |
local y = (other.y - self.y) | |
local z = (other.z - self.z) | |
return math.sqrt(x*x + y*y + z*z) | |
end | |
function Coord:establishGridline(otherPoint, control) | |
-- UNTESTED OPTIMIZATION | |
if otherPoint.x ~= self.x then | |
local offset = sign(self.x - otherPoint.x) | |
self.gridlines[offset] = self.gridlines[offset] or {} | |
self.gridlines[offset][0] = control | |
else | |
local offset = sign(self.z - otherPoint.z) | |
self.gridlines[0] = self.gridlines[0] or {} | |
self.gridlines[0][offset] = control | |
end | |
end | |
function Coord:getGridlineFor(otherPoint) | |
-- UNTESTED OPTIMIZATION | |
if otherPoint.x ~= self.x then | |
local offset = sign(self.x - otherPoint.x) | |
self.gridlines[offset] = self.gridlines[offset] or {} | |
return self.gridlines[offset][0] | |
else | |
local offset = sign(self.z - otherPoint.z) | |
self.gridlines[0] = self.gridlines[0] or {} | |
return self.gridlines[0][offset] | |
end | |
end | |
function Coord:midpointTo(other) | |
local x = (other.x + self.x) / 2 | |
local y = (other.y + self.y) / 2 | |
local z = (other.z + self.z) / 2 | |
return x, y, z | |
end | |
function Coord:update(y, lateralDistance, properties) | |
if lateralDistance <= self.lateralDistance and y < self.y then | |
self.y = y | |
self.lateralDistance = lateralDistance | |
-- | |
local props = properties or {} | |
self.isWater = props.isWater or false | |
end | |
end | |
end | |
local Plane = {} | |
do | |
Plane.meta = { __index = Plane } | |
function Plane:new(ownerMap) | |
assert(ownerMap ~= nil) | |
local result = setmetatable({}, self.meta) | |
result.owner = ownerMap | |
result.coords = {} | |
return result | |
end | |
function Plane:forEachCoord(functor) | |
for x, list in pairs(self.coords) do | |
for z, point in pairs(list) do | |
if functor(point, self) then | |
return | |
end | |
end | |
end | |
end | |
function Plane:getHeightAt(x, z) | |
local point = self.coords[x] | |
if point then | |
point = point[z] | |
if point then | |
return point.y | |
end | |
end | |
end | |
function Plane:getCoord(x, z) | |
local list = self.coords[x] | |
if list then | |
return list[z] | |
end | |
end | |
function Plane:setHeightAt(x, y, z, props) | |
local lateralDistance = 0 | |
do -- round coordinates to resolution | |
local ld = 0 | |
local roundX = round(x, self.owner.resolution) | |
local roundZ = round(z, self.owner.resolution) | |
local dX = roundX - x | |
local dZ = roundZ - z | |
lateralDistance = math.sqrt(dX * dX + dZ * dZ) | |
x = roundX | |
y = round(y) | |
z = roundZ | |
end | |
local a = self.coords[x] or {} | |
local b = a[z] | |
if b then | |
b:update(y, lateralDistance, props) | |
else | |
a[z] = Coord:new(x, y, z, lateralDistance, props) | |
end | |
self.coords[x] = a | |
end | |
end | |
local Map = {} | |
do | |
Map.meta = { __index = Map } | |
function Map:new(zoneId, resolution) | |
local result = setmetatable({}, self.meta) | |
result.zoneId = zoneId | |
result.name = GetZoneNameById(zoneId) | |
-- | |
result.planes = {} | |
result.resolution = resolution or 175 | |
result.tolerance = 20 | |
return result | |
end | |
function Map:forEachCoord(planeIndex, functor) | |
local plane = self:getOrCreatePlane(planeIndex) | |
plane:forEachCoord(functor) | |
end | |
function Map:getOrCreatePlane(plane) | |
plane = plane or 1 | |
if not self.planes[plane] then | |
self.planes[plane] = Plane:new(self) | |
end | |
return self.planes[plane] | |
end | |
function Map:getHeightAt(planeIndex, x, y) | |
local plane = self:getOrCreatePlane(planeIndex) | |
return plane:getHeightAt(x, y) | |
end | |
function Map:setHeightAt(planeIndex, x, y, z, props) | |
local plane = self:getOrCreatePlane(planeIndex) | |
return plane:setHeightAt(x, y, z, props) | |
end | |
end | |
local MapDatabase = {} | |
do | |
MapDatabase.meta = { __index = MapDatabase } | |
function MapDatabase:new() | |
local result = setmetatable({}, self.meta) | |
result.zones = {} | |
return result | |
end | |
function MapDatabase:getOrCreateZone(id) | |
local map = self.zones[id] | |
if not map then | |
map = Map:new(id) | |
self.zones[id] = map | |
end | |
return map | |
end | |
function MapDatabase:getZone(id) | |
return self.zones[id] or nil | |
end | |
end | |
local MAP_DATABASE = MapDatabase:new() | |
CobbMapTestMapDB = MAP_DATABASE | |
local currentPlane = 1 | |
local ui = { | |
gridlinePool = nil, | |
} | |
local function _clearRenderedGrid() | |
for i = 1, CobbMapHelperGridlines:GetNumChildren() do | |
local control = CobbMapHelperGridlines:GetChild(i) | |
control:SetHidden(true) | |
control:Destroy3DRenderSpace() | |
end | |
end | |
local function _renderGrid() | |
local THICKNESS = 0.1 | |
-- | |
local zID, x, y, z = GetUnitWorldPosition("player") | |
local map = MAP_DATABASE:getZone(zID) | |
if not map then | |
d("No map exists for this zone.") | |
return | |
end | |
-- | |
local oldCount = CobbMapHelperGridlines:GetNumChildren() | |
local renderCount = 0 | |
local resolution = map.resolution | |
--d("drawing a grid at resolution " .. tostring(resolution)) | |
map:forEachCoord(currentPlane, function(point, plane) | |
local x = point.x | |
local z = point.z | |
-- | |
local function _getControl() | |
renderCount = renderCount + 1 | |
local control | |
if renderCount <= oldCount then | |
control = CobbMapHelperGridlines:GetChild(renderCount) | |
else | |
local key | |
control, key = ui.gridlinePool:AcquireObject() | |
end | |
return control | |
end | |
local function _drawGridline(a, b) -- args are Coord structs | |
local function _normalizeDistance(d) | |
local x0 = WorldPositionToGuiRender3DPosition(0, 0, 0) | |
local nd = WorldPositionToGuiRender3DPosition(d, 0, 0) | |
return nd - x0 | |
end | |
-- | |
-- UNTESTED OPTIMIZATION | |
local control = a:getGridlineFor(b) or b:getGridlineFor(a) | |
if not control then | |
control = _getControl() | |
end | |
control.point1 = a | |
control.point2 = b | |
a:establishGridline(b, control) | |
b:establishGridline(a, control) | |
-- | |
if not control:Has3DRenderSpace() then | |
control:Create3DRenderSpace() | |
control:Set3DRenderSpaceUsesDepthBuffer(false) | |
end | |
local xm, ym, zm = a:midpointTo(b) | |
xm, ym, zm = WorldPositionToGuiRender3DPosition(xm, ym, zm) | |
local distance = _normalizeDistance(a:distanceTo(b)) | |
control:Set3DRenderSpaceOrigin(xm, ym, zm) | |
control:Set3DLocalDimensions(distance + THICKNESS, THICKNESS) | |
do -- rotation | |
local yaw = 0 | |
local pitch = 0 | |
local roll = 0 | |
local height = b.y - a.y | |
local slope = math.atan2(height, resolution) | |
if a.x == b.x then | |
pitch = 0 | |
yaw = math.rad(90) + slope | |
roll = math.rad(90) | |
control:SetColor(0.25, 0.80, 0.10, 1) | |
else | |
pitch = math.rad(90) | |
yaw = 0 | |
roll = slope | |
control:SetColor(1, 0, 0, 1) | |
end | |
control:Set3DRenderSpaceOrientation(pitch, yaw, roll) | |
end | |
-- | |
control:SetHidden(false) | |
end | |
local function _drawPoint(point) | |
--d("rendering orphan point (child " .. renderCount .. "...") | |
local control = _getControl() | |
control.point1 = point | |
control.point2 = point | |
if not control:Has3DRenderSpace() then | |
control:Create3DRenderSpace() | |
control:Set3DRenderSpaceUsesDepthBuffer(false) | |
end | |
local ux, uy, uz = WorldPositionToGuiRender3DPosition(point.x, point.y, point.z) | |
control:Set3DRenderSpaceOrigin(ux, uy, uz) | |
control:Set3DLocalDimensions(THICKNESS, THICKNESS) | |
control:SetColor(1, 0, 0, 1) | |
-- | |
control:SetHidden(false) | |
end | |
-- | |
local function _renderAdjacent(xOffset, zOffset) | |
local otherX = x + (resolution * xOffset) | |
local otherZ = z + (resolution * zOffset) | |
local other = plane:getCoord(otherX, otherZ) | |
if not other then | |
return false | |
end | |
--d(LocalizeString("(<<1>>, <<2>>) rendering connection to (<<3>>, <<4>>) given offsets (<<5>>, <<6>>)", x, z, otherX, otherZ, xOffset, zOffset)) | |
_drawGridline(point, other) | |
return true | |
end | |
local hasAdjacent = false | |
hasAdjacent = _renderAdjacent(1, 0) or hasAdjacent | |
hasAdjacent = _renderAdjacent(0, 1) or hasAdjacent | |
do | |
local west = plane:getCoord(x + resolution, z) | |
local north = plane:getCoord(x, -resolution + z) | |
hasAdjacent = hasAdjacent or (west or north) | |
end | |
--d(LocalizeString("rendered connections for (<<1>>, <<2>>)", x, z)) | |
if not hasAdjacent then | |
_drawPoint(point) | |
end | |
end) | |
for i = 1, CobbMapHelperGridlines:GetNumChildren() do | |
-- UNTESTED OPTIMIZATION | |
local control = CobbMapHelperGridlines:GetChild(i) | |
local a = control.point1 | |
local b = control.point2 | |
if a and b then | |
local established = a:getGridlineFor(b) | |
if established ~= control then | |
control.point1 = nil | |
control.point2 = nil | |
control:SetHidden(true) | |
control:Destroy3DRenderSpace() | |
end | |
else | |
control.point1 = nil | |
control.point2 = nil | |
control:SetHidden(true) | |
control:Destroy3DRenderSpace() | |
end | |
end | |
end | |
local function Init() | |
do | |
ui.gridlinePool = ZO_ControlPool:New("CobbMapHelperGridlineTmpl", CobbMapHelperGridlines, "CobbMapHelperGridlineTmpl") | |
end | |
CobbMapHelperGridlines:Create3DRenderSpace() | |
local fragment = ZO_SimpleSceneFragment:New(CobbMapHelperGridlines) | |
HUD_UI_SCENE:AddFragment(fragment) | |
HUD_SCENE:AddFragment(fragment) | |
LOOT_SCENE:AddFragment(fragment) | |
Lib3D:RegisterWorldChangeCallback("CobbMapHelperOnWorldChange", function(identifier, zoneIndex, isValidZone, newZone) | |
_clearRenderedGrid() | |
local wx, wy, wz = WorldPositionToGuiRender3DPosition(0, 0, 0) | |
-- | |
-- Lib3D says you need to use the "GUI render 3D position" as a basis, | |
-- but I only saw correct behavior by avoiding that?? | |
-- | |
-- I use WorldPositionToGuiRender3DPosition for each individual gridline | |
-- and maybe that's incorrect behavior i.e. I may be doing there what I | |
-- should be doing here. I should test that later | |
-- | |
wx = 0 | |
wy = 0 | |
wz = 0 | |
CobbMapHelperGridlines:Set3DRenderSpaceOrigin(wx, wy, wz) | |
end) | |
SLASH_COMMANDS["/cobbmapgrid"] = function() | |
_renderGrid() | |
end | |
-- | |
local function _poller() | |
if not IsPlayerMoving() then | |
return | |
end | |
local zID, x, y, z = GetUnitWorldPosition("player") | |
if zID and x then | |
local map = MAP_DATABASE:getOrCreateZone(zID) | |
local props = { | |
isWater = IsUnitSwimming("player"), | |
} | |
map:setHeightAt(currentPlane, x, y, z, props) | |
end | |
end | |
EVENT_MANAGER:RegisterForUpdate("CobbMapHelperPositionPoll", 100, _poller) | |
EVENT_MANAGER:RegisterForEvent("CobbMapHelperMountListener", EVENT_MOUNTED_STATE_CHANGED, function(eventCode, mounted) | |
if mounted then | |
EVENT_MANAGER:RegisterForUpdate("CobbMapHelperPositionPoll", 20, _poller) | |
else | |
EVENT_MANAGER:RegisterForUpdate("CobbMapHelperPositionPoll", 100, _poller) | |
end | |
end) | |
EVENT_MANAGER:RegisterForUpdate("CobbMapHelperRedraw", 200, function() | |
if not IsPlayerMoving() then | |
return | |
end | |
_renderGrid() | |
end) | |
end | |
EVENT_MANAGER:RegisterForEvent("CobbMapHelperOnLoad", EVENT_ADD_ON_LOADED, function(eventCode, addonName) | |
if addonName ~= "CobbMapHelper" then | |
return | |
end | |
Init() | |
end) | |
CobbMapHelper = {} | |
function CobbMapHelper.renderTest(pitch, yaw, roll) | |
if not CobbMapHelperRenderTest:Has3DRenderSpace() then | |
CobbMapHelperRenderTest:Create3DRenderSpace() | |
CobbMapHelperRenderTest:Set3DRenderSpaceUsesDepthBuffer(false) | |
end | |
do | |
local wx, wy, wz = WorldPositionToGuiRender3DPosition(0, 0, 0) | |
wx = 0 | |
wy = 0 | |
wz = 0 | |
CobbMapHelperRenderTest:Set3DRenderSpaceOrigin(wx, wy, wz) | |
end | |
local tex = CobbMapHelperRenderTest:GetChild(1) | |
if not tex:Has3DRenderSpace() then | |
tex:Create3DRenderSpace() | |
tex:Set3DRenderSpaceUsesDepthBuffer(false) | |
end | |
local zID, x, y, z = GetUnitWorldPosition("player") | |
local ux, uy, uz = WorldPositionToGuiRender3DPosition(x, y, z) | |
tex:Set3DRenderSpaceOrigin(ux, uy, uz) | |
tex:Set3DLocalDimensions(3, 1) | |
tex:Set3DRenderSpaceOrientation(math.rad(pitch), math.rad(yaw), math.rad(roll)) | |
CobbMapHelperRenderTest:SetHidden(false) | |
end |
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
<GuiXml> | |
<Controls> | |
<Texture name="CobbMapHelperGridlineTmpl" virtual="true" | |
color="FF0000FF" | |
width="1" | |
height="1" | |
> | |
<Controls> | |
</Controls> | |
</Texture> | |
<TopLevelControl name="CobbMapHelperGridlines"> | |
<Controls> | |
</Controls> | |
</TopLevelControl> | |
<TopLevelControl name="CobbMapHelperRenderTest" hidden="true"> | |
<Controls> | |
<Texture name="CobbMapHelperGridlineTmpl" | |
color="FF0080FF" | |
width="1" | |
height="1" | |
/> | |
</Controls> | |
</TopLevelControl> | |
</Controls> | |
</GuiXml> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment