Initial commit

This commit is contained in:
2026-03-08 01:34:54 +08:00
commit 1f104f73c8
441 changed files with 64911 additions and 0 deletions

92
tools/blog/README.md Normal file
View File

@@ -0,0 +1,92 @@
# 知乎博客HTML解析工具
## 功能说明
该工具用于将下载的知乎博客HTML文件解析为标准Markdown格式方便后续编辑和管理。
- 支持提取文章标题
- 支持提取正文内容(段落、图片、代码块等)
- 支持将HTML元素转换为对应的Markdown格式
- 自动生成同名的.md文件
## 安装依赖
在使用前,需要安装以下依赖库:
```bash
pip install -r requirements.txt
```
依赖库说明:
- `beautifulsoup4` - 用于解析HTML结构
- `lxml` - 作为BeautifulSoup的解析器
- `markdownify` - 用于将HTML转换为Markdown
## 使用方法
### 基本用法
```bash
python parse_blog.py <html_file_path>
```
例如:
```bash
python parse_blog.py "(6 封私信 _ 14 条消息) 高质量Mesh体积光渲染 - 知乎.html"
```
### 输出结果
执行命令后,工具会在同一目录下生成同名的.md文件例如
- 输入:`(6 封私信 _ 14 条消息) 高质量Mesh体积光渲染 - 知乎.html`
- 输出:`(6 封私信 _ 14 条消息) 高质量Mesh体积光渲染 - 知乎.md`
## 支持的元素
- 标题h1-h6
- 段落p
- 图片img
- 代码块pre code
- 引用块blockquote
- 列表ul, ol
- 链接a
## 注意事项
1. 该工具仅支持解析知乎博客HTML文件其他网站的HTML可能无法正确解析
2. 为了获得最佳解析效果,建议使用浏览器的"保存页面为HTML"功能下载完整的HTML文件
3. 解析过程中可能会遇到一些特殊元素无法完全转换,此时会使用默认处理方式
## 故障排除
如果遇到解析失败的情况,可以尝试以下方法:
1. 确保HTML文件是完整的包含所有必要的结构
2. 检查是否已正确安装所有依赖库
3. 查看命令行输出的错误信息,根据提示进行修复
## 示例
### 输入输出示例
**输入**知乎HTML文件
**输出**
```markdown
# 高质量Mesh体积光渲染
SpotLight是Unity里面常用的灯光类型我们渲染它的时候按照生活常识来说需要渲染两个部分一个是照亮东西的效果如下图
![image](https://pic1.zhimg.com/v2-ff65743535c522dc74d58b87a6cc0d85_r.jpg)
另外一个当然就是舞台上经常看到的光柱效果,学名叫体积光,如下图所示:
![image](https://pic1.zhimg.com/v2-5cb0ae88a5fcdd32f26c703f83655089_r.jpg)
这篇文章就主要讲一下如何实现高质量的体积光效果。
```
## 许可证
本工具采用MIT许可证可自由使用和修改。

160
tools/blog/parse_blog.py Normal file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
知乎博客HTML解析为Markdown工具
功能将下载的知乎HTML文件解析为标准Markdown格式
使用python parse_blog.py <html_file_path>
"""
import os
import sys
from bs4 import BeautifulSoup
from markdownify import markdownify as md
def parse_zhihu_blog(html_file):
"""
解析知乎博客HTML文件并转换为Markdown
Args:
html_file: HTML文件路径
Returns:
str: 转换后的Markdown内容
"""
try:
# 读取HTML文件
with open(html_file, 'r', encoding='utf-8') as f:
html_content = f.read()
# 解析HTML
soup = BeautifulSoup(html_content, 'lxml')
# 提取标题
title = ""
title_tag = soup.find('h1', class_='Post-Title')
if not title_tag:
title_tag = soup.find('h1')
if title_tag:
title = title_tag.get_text(strip=True)
# 提取正文内容
content = ""
# 知乎文章正文的常见容器
content_containers = [
soup.find('div', class_='Post-RichTextContainer'),
soup.find('div', class_='RichText ztext Post-RichText'),
soup.find('article', class_='Post-content'),
soup.find('div', class_='Post-content')
]
# 尝试找到第一个有效的内容容器
content_container = None
for container in content_containers:
if container:
content_container = container
break
# 如果找到了内容容器,提取内容
if content_container:
# 预处理:移除不需要的元素
for element in content_container.find_all(['script', 'style', 'iframe', 'noscript']):
element.decompose()
# 处理图片路径
for img in content_container.find_all('img'):
if 'src' in img.attrs:
src = img['src']
# 处理相对路径
if src.startswith('./'):
# 保持相对路径不变
pass
# 处理绝对路径
elif src.startswith('http'):
# 保持绝对路径不变
pass
# 处理链接
for a in content_container.find_all('a'):
if 'href' in a.attrs:
href = a['href']
# 处理知乎内部链接
if href.startswith('/'):
a['href'] = f"https://www.zhihu.com{href}"
# 转换为Markdown
content = md(str(content_container),
heading_style="ATX",
code_language="",
wrap_width=0)
else:
# 如果没有找到特定容器尝试提取所有p标签内容
paragraphs = soup.find_all('p')
content = "\n".join([p.get_text(strip=True) for p in paragraphs])
# 后处理清理Markdown内容
# 移除多余的空行
content = '\n'.join([line for line in content.split('\n') if line.strip() or line == ''])
# 清理重复的换行
while '\n\n\n' in content:
content = content.replace('\n\n\n', '\n\n')
# 组合标题和内容
markdown_content = f"# {title}\n\n{content}"
return markdown_content
except Exception as e:
print(f"解析出错: {e}")
return ""
def save_markdown(content, output_file):
"""
保存Markdown内容到文件
Args:
content: Markdown内容
output_file: 输出文件路径
"""
try:
with open(output_file, 'w', encoding='utf-8') as f:
f.write(content)
print(f"Markdown文件已保存: {output_file}")
except Exception as e:
print(f"保存文件出错: {e}")
def main():
"""
主函数
"""
if len(sys.argv) != 2:
print("使用方法: python parse_blog.py <html_file_path>")
sys.exit(1)
html_file = sys.argv[1]
if not os.path.exists(html_file):
print(f"文件不存在: {html_file}")
sys.exit(1)
# 生成输出文件路径
base_name = os.path.splitext(html_file)[0]
output_file = f"{base_name}.md"
# 解析HTML并转换为Markdown
print(f"正在解析: {html_file}")
markdown_content = parse_zhihu_blog(html_file)
if markdown_content:
# 保存为Markdown文件
save_markdown(markdown_content, output_file)
else:
print("解析失败无法生成Markdown文件")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,3 @@
beautifulsoup4
lxml
markdownify

15
tools/blog/test_output.md Normal file
View File

@@ -0,0 +1,15 @@
# SIGGRAPH 2025papers on the web
Page maintained byKe-Sen Huang.
If you have additions or changes, send ane-mail.
Information here is provided with the permission of the ACM
Note that when possible I link to the page containing the link to the actual PDF or PS of the preprint.
I prefer this as it gives some context to the paper and avoids possible copyright problems with direct linking.
Thus you may need to search on the page to find the actual document.
ACM Digital Library:ACM Transactions on Graphics (TOG) Volume 44, Issue 4 (July 2025) Proceedings of ACM SIGGRAPH 2025
SIG/TOG:Journal Paper for presentation at SIGGRAPH 2025
SIG:Conference Paper for presentation at SIGGRAPH 2025
TOG:Selected ACM TOG Paper for presentation at SIGGRAPH 2025
ACM Digital Library (DOI)Link for the paperPaper AbstractAuthor PreprintPaper Video
Paper PresentationPaper ImagesPaper DataDemo Program or Source CodeRelated Links
Changelog

35
tools/blog/test_parse.py Normal file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
测试解析功能的脚本
"""
from parse_blog import parse_zhihu_blog, save_markdown
def test_parse():
"""
测试解析功能
"""
# 直接指定HTML文件路径
html_file = "SIGGRAPH 2025 Papers.html"
output_file = "test_output.md"
print(f"测试解析: {html_file}")
# 解析HTML
markdown_content = parse_zhihu_blog(html_file)
if markdown_content:
print(f"解析成功,内容长度: {len(markdown_content)}")
print("\n前500个字符预览:")
print(markdown_content[:500] + "...")
# 保存到文件
save_markdown(markdown_content, output_file)
else:
print("解析失败")
if __name__ == "__main__":
test_parse()

View File

@@ -0,0 +1,6 @@
pyperclip
Pillow
pywin32
beautifulsoup4
markdownify
requests

View File

@@ -0,0 +1,250 @@
import os
import datetime
import re
import base64
import shutil
import requests
import random
from urllib.parse import unquote, urlparse
from io import BytesIO
# Third-party libraries
import pyperclip
from PIL import ImageGrab, Image
import win32clipboard
from bs4 import BeautifulSoup
from markdownify import markdownify as md
# Configuration
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
MARKDOWN_DIR = os.path.join(PROJECT_ROOT, 'notebook', 'markdowns')
IMAGES_DIR = os.path.join(PROJECT_ROOT, 'notebook', 'images')
def sanitize_filename(name):
"""Sanitize string to be used as filename"""
safe_name = "".join([c for c in name if c.isalnum() or c in (' ', '-', '_')]).strip()
return safe_name[:100]
def get_html_from_clipboard():
"""Extract HTML format from Windows Clipboard"""
try:
win32clipboard.OpenClipboard()
# Register/Get HTML Format ID
html_format = win32clipboard.RegisterClipboardFormat("HTML Format")
if win32clipboard.IsClipboardFormatAvailable(html_format):
raw_data = win32clipboard.GetClipboardData(html_format)
win32clipboard.CloseClipboard()
# Raw data contains headers, we need to decode and parse them
# Example Header:
# Version:0.9
# StartHTML:00000097
# EndHTML:00000170
# StartFragment:00000133
# EndFragment:00000134
try:
html_str = raw_data.decode('utf-8')
except:
html_str = raw_data.decode('cp1252', errors='ignore')
# Extract the actual HTML fragment using regex or string splitting
start_html = re.search(r'StartHTML:(\d+)', html_str)
end_html = re.search(r'EndHTML:(\d+)', html_str)
if start_html and end_html:
start_idx = int(start_html.group(1))
end_idx = int(end_html.group(1))
return html_str[start_idx:end_idx]
return html_str # Fallback to full string if parsing fails
win32clipboard.CloseClipboard()
return None
except Exception as e:
print(f"Error reading clipboard HTML: {e}")
try:
win32clipboard.CloseClipboard()
except:
pass
return None
def process_html_images(html_content, timestamp):
"""Find images in HTML, save them locally, and update src"""
soup = BeautifulSoup(html_content, 'html.parser')
for img in soup.find_all('img'):
src = img.get('src')
if not src:
continue
new_filename = None
# Case 1: Base64 Image
if src.startswith('data:image'):
try:
# Extract header and data
# data:image/png;base64,xxxx
match = re.match(r'data:image/(\w+);base64,(.+)', src)
if match:
ext = match.group(1)
if ext == 'jpeg': ext = 'jpg'
data_str = match.group(2)
img_data = base64.b64decode(data_str)
new_filename = f"paste_img_{timestamp}_{random.randint(1000,9999)}.{ext}"
dest_path = os.path.join(IMAGES_DIR, new_filename)
with open(dest_path, 'wb') as f:
f.write(img_data)
print(f" - Saved Base64 image: {new_filename}")
except Exception as e:
print(f" - Failed to process base64 image: {e}")
# Case 2: Local File (file://)
elif src.startswith('file://'):
try:
# Remove file:// prefix and decode URL encoded chars
local_path = unquote(src[7:])
# On Windows, it might be file:///C:/... -> /C:/... -> C:/...
if local_path.startswith('/') and ':' in local_path:
local_path = local_path[1:]
if os.path.exists(local_path):
ext = os.path.splitext(local_path)[1]
if not ext: ext = '.png'
new_filename = f"paste_img_{timestamp}_{random.randint(1000,9999)}{ext}"
dest_path = os.path.join(IMAGES_DIR, new_filename)
shutil.copy2(local_path, dest_path)
print(f" - Copied local image: {new_filename}")
except Exception as e:
print(f" - Failed to copy local image: {e}")
# Case 3: Remote URL (http/https)
# Optional: We could download it, but for now let's keep it as is
# or download if it's a direct image link.
# Let's try to download to make it truly local/offline
elif src.startswith('http'):
try:
# Basic check if it's an image
# We skip downloading if user wants to keep remote links, but usually local is better for notes
# Let's try downloading
response = requests.get(src, timeout=5)
if response.status_code == 200 and 'image' in response.headers.get('content-type', ''):
ext = '.jpg' # default
if 'png' in response.headers['content-type']: ext = '.png'
elif 'gif' in response.headers['content-type']: ext = '.gif'
new_filename = f"paste_img_{timestamp}_{random.randint(1000,9999)}{ext}"
dest_path = os.path.join(IMAGES_DIR, new_filename)
with open(dest_path, 'wb') as f:
f.write(response.content)
print(f" - Downloaded remote image: {new_filename}")
except Exception as e:
print(f" - Failed to download remote image: {e}")
# Update src in HTML if we saved a file
if new_filename:
# We use bare filename as requested
img['src'] = new_filename
return str(soup)
def extract_title_from_markdown(md_text):
"""Try to find the first H1 header to use as title"""
lines = md_text.strip().split('\n')
for line in lines[:10]:
if line.strip().startswith('# '):
return sanitize_filename(line.strip()[2:])
return None
def save_clipboard_content():
# Ensure directories exist
os.makedirs(MARKDOWN_DIR, exist_ok=True)
os.makedirs(IMAGES_DIR, exist_ok=True)
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
# Priority 1: Check for HTML (Rich Text)
# This covers most "Copy from Note App" scenarios
html_content = get_html_from_clipboard()
if html_content:
print("Detected HTML/Rich Text in clipboard...")
# 1. Process Images in HTML (Save to disk)
processed_html = process_html_images(html_content, timestamp)
# 2. Convert to Markdown
# heading_style='atx' ensures # Header style instead of underlines
md_text = md(processed_html, heading_style='atx')
# 3. Clean up extra newlines often introduced by conversion
md_text = re.sub(r'\n{3,}', '\n\n', md_text).strip()
# 4. Determine Filename
title = extract_title_from_markdown(md_text)
markdown_filename = f"{title}.md" if title else f"Note_{timestamp}.md"
# 5. Save
markdown_path = os.path.join(MARKDOWN_DIR, markdown_filename)
# Avoid collision
if os.path.exists(markdown_path):
markdown_filename = f"{title}_{timestamp}.md" if title else f"Note_{timestamp}_1.md"
markdown_path = os.path.join(MARKDOWN_DIR, markdown_filename)
with open(markdown_path, 'w', encoding='utf-8') as f:
f.write(md_text)
print(f"Saved Rich Text Note to: {markdown_path}")
return
# Priority 2: Check for Bitmap Image (Direct Screenshot Copy)
try:
image = ImageGrab.grabclipboard()
if image and not isinstance(image, list):
print("Detected BITMAP image in clipboard...")
image_filename = f"clip_image_{timestamp}.png"
image_path = os.path.join(IMAGES_DIR, image_filename)
image.save(image_path, 'PNG')
markdown_filename = f"Image_{timestamp}.md"
markdown_path = os.path.join(MARKDOWN_DIR, markdown_filename)
# Use bare filename as requested
content = f"# Clipboard Image {timestamp}\n\n![{image_filename}]({image_filename})"
with open(markdown_path, 'w', encoding='utf-8') as f:
f.write(content)
print(f"Saved image to: {image_path}")
print(f"Saved markdown to: {markdown_path}")
return
except Exception as e:
print(f"Error checking bitmap: {e}")
# Priority 3: Fallback to Plain Text
text = pyperclip.paste()
if text:
print("Detected PLAIN TEXT in clipboard...")
title = extract_title_from_markdown(text)
markdown_filename = f"{title}.md" if title else f"Note_{timestamp}.md"
markdown_path = os.path.join(MARKDOWN_DIR, markdown_filename)
if os.path.exists(markdown_path):
markdown_filename = f"{title}_{timestamp}.md" if title else f"Note_{timestamp}_1.md"
markdown_path = os.path.join(MARKDOWN_DIR, markdown_filename)
with open(markdown_path, 'w', encoding='utf-8') as f:
f.write(text)
print(f"Saved Text Note to: {markdown_path}")
else:
print("Clipboard is empty.")
if __name__ == "__main__":
save_clipboard_content()

158
tools/doubao/main.py Normal file
View File

@@ -0,0 +1,158 @@
import os
import sys
import argparse
import time
from openai import OpenAI
# 任务定义
TASKS = {
"fix_markdown": {
"system": "你是一个 Markdown 格式专家。请修复以下 Markdown 文档片段的格式问题。不要修改文档的原始内容,只调整格式(如标题、列表、代码块等的规范化)。直接输出修复后的 Markdown 内容,不要包含任何解释或 ```markdown 标记。注意:这是长文档的一部分,请保持上下文连贯。",
}
}
# 最大块大小(字符数)
MAX_CHUNK_SIZE = 3000
def split_markdown(text, max_length=MAX_CHUNK_SIZE):
"""
将 Markdown 文本分割成较小的块,尽量保持段落和代码块完整。
"""
lines = text.split('\n')
chunks = []
current_chunk = []
current_length = 0
in_code_block = False
for line in lines:
# 检测代码块状态
if line.strip().startswith('```'):
in_code_block = not in_code_block
line_len = len(line) + 1 # +1 for newline
# 决定是否需要切分:
# 1. 当前长度超过最大限制
# 2. 且不在代码块内 (in_code_block == False)
if current_length + line_len > max_length and not in_code_block:
# 如果当前块不为空,则保存当前块
if current_chunk:
chunks.append('\n'.join(current_chunk))
current_chunk = []
current_length = 0
# 如果单行本身就超过了最大长度(极少见情况),也只能强行放入
current_chunk.append(line)
current_length += line_len
else:
current_chunk.append(line)
current_length += line_len
if current_chunk:
chunks.append('\n'.join(current_chunk))
return chunks
def process_chunk(client, content, task_config, model="doubao-seed-1-8-251228"):
"""
处理单个文本块
"""
try:
completion = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": task_config["system"]},
{"role": "user", "content": content},
],
max_tokens=4096, # 保持较大的输出 token 限制
)
return completion.choices[0].message.content
except Exception as e:
# 如果出错,打印错误到 stderr 但不中断整个流程(或者选择中断)
# 这里选择抛出异常以便外层捕获
raise e
def main():
parser = argparse.ArgumentParser(description="Doubao AI Task Executor")
parser.add_argument("--task", required=True, help="Task name", choices=TASKS.keys())
args = parser.parse_args()
# 优先从环境变量读取,如果没有则使用硬编码的 Key (仅供演示,实际应走环境变量)
api_key = os.getenv('ARK_API_KEY') or "a5ab502d-c9a9-49f3-a80b-9c80c6b5378b"
if not api_key:
print("Error: ARK_API_KEY environment variable is not set.", file=sys.stderr)
sys.exit(1)
client = OpenAI(
base_url="https://ark.cn-beijing.volces.com/api/v3",
api_key=api_key,
)
# Windows UTF-8 处理
if sys.platform == 'win32':
import io
sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8', errors='ignore')
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='ignore')
# 读取全部内容
content = sys.stdin.read()
# 清洗非法字符
content = content.encode('utf-8', 'ignore').decode('utf-8')
if not content:
print("Error: No input content provided via stdin.", file=sys.stderr)
sys.exit(1)
task_config = TASKS[args.task]
# 1. 分割文本
chunks = split_markdown(content)
# 2. 依次处理
results = []
total_chunks = len(chunks)
# 打印进度信息到 stderr (前端看不到,但方便调试)
print(f"Processing {total_chunks} chunks...", file=sys.stderr)
for i, chunk in enumerate(chunks):
try:
# 简单的重试机制
retry_count = 0
max_retries = 3
result = None
while retry_count < max_retries:
try:
result = process_chunk(client, chunk, task_config)
break
except Exception as e:
retry_count += 1
print(f"Chunk {i+1}/{total_chunks} failed (attempt {retry_count}): {e}", file=sys.stderr)
time.sleep(2) # 等待后重试
if result is None:
print(f"Error: Failed to process chunk {i+1} after {max_retries} attempts.", file=sys.stderr)
# 失败时保留原始内容,避免数据丢失
results.append(chunk)
else:
results.append(result)
# 避免触发速率限制
if i < total_chunks - 1:
time.sleep(0.5)
except Exception as e:
print(f"Critical error on chunk {i+1}: {e}", file=sys.stderr)
results.append(chunk)
# 3. 合并输出
final_output = '\n'.join(results)
# 4. 打印最终结果
print(final_output)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1 @@
openai

0
tools/mineru/api.md Normal file
View File

21
tools/mineru/config.py Normal file
View File

@@ -0,0 +1,21 @@
import os
from dotenv import load_dotenv
# 加载环境变量
load_dotenv()
# Mineru API 配置
MINERU_API_URL = "https://mineru.net/api/v4"
MINERU_TOKEN = "eyJ0eXBlIjoiSldUIiwiYWxnIjoiSFM1MTIifQ.eyJqdGkiOiI0NDIwMDY1NyIsInJvbCI6IlJPTEVfUkVHSVNURVIiLCJpc3MiOiJPcGVuWExhYiIsImlhdCI6MTc3MDIyMDU4OSwiY2xpZW50SWQiOiJsa3pkeDU3bnZ5MjJqa3BxOXgydyIsInBob25lIjoiIiwib3BlbklkIjpudWxsLCJ1dWlkIjoiMTE4MDA1YjctYWRiYy00MmY0LTkyZTYtZWM4M2Q1ZWRiOTQzIiwiZW1haWwiOiIiLCJleHAiOjE3NzE0MzAxODl9.cVCGxc97GNCdQPYmaP9hbYptfenAK6o8xJ0CZtEOxOhOEgVhV519P7X61FmdLgSs4QRZYs0ZM_4VRwQgVFnJ0w"
# 阿里云 OSS 配置
OSS_ACCESS_KEY_ID = "LTAI5tB7sQADpKZnXY7s6Xz8"
OSS_ACCESS_KEY_SECRET = "Fgab9klwKoH1GACP97WIb7s6BSvNAm"
OSS_BUCKET_NAME = "bucket-xcnote" # 测试用的 OSS 桶名称
OSS_ENDPOINT = "https://oss-cn-beijing.aliyuncs.com"
# 本地文件配置
TEMP_DIR = "./temp"
# 确保临时目录存在
os.makedirs(TEMP_DIR, exist_ok=True)

3
tools/mineru/key.md Normal file
View File

@@ -0,0 +1,3 @@
xuanchi
eyJ0eXBlIjoiSldUIiwiYWxnIjoiSFM1MTIifQ.eyJqdGkiOiI0NDIwMDY1NyIsInJvbCI6IlJPTEVfUkVHSVNURVIiLCJpc3MiOiJPcGVuWExhYiIsImlhdCI6MTc3MDIyMDU4OSwiY2xpZW50SWQiOiJsa3pkeDU3bnZ5MjJqa3BxOXgydyIsInBob25lIjoiIiwib3BlbklkIjpudWxsLCJ1dWlkIjoiMTE4MDA1YjctYWRiYy00MmY0LTkyZTYtZWM4M2Q1ZWRiOTQzIiwiZW1haWwiOiIiLCJleHAiOjE3NzE0MzAxODl9.cVCGxc97GNCdQPYmaP9hbYptfenAK6o8xJ0CZtEOxOhOEgVhV519P7X61FmdLgSs4QRZYs0ZM_4VRwQgVFnJ0w

View File

@@ -0,0 +1,326 @@
#!/usr/bin/env python
# coding=utf-8
import os
import time
import requests
import zipfile
import json
from config import (
MINERU_API_URL,
MINERU_TOKEN,
OSS_ACCESS_KEY_ID,
OSS_ACCESS_KEY_SECRET,
OSS_BUCKET_NAME,
OSS_ENDPOINT,
TEMP_DIR,
)
import sys
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from shared.oss_upload import upload_file_to_oss
def create_parse_task(url, model_version="vlm"):
"""
创建解析任务
:param url: 文件URL
:param model_version: 模型版本默认为vlm
:return: 任务ID
"""
print(f"开始创建解析任务: {url}")
api_url = f"{MINERU_API_URL}/extract/task"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {MINERU_TOKEN}",
}
data = {"url": url, "model_version": model_version}
try:
response = requests.post(api_url, headers=headers, json=data)
response.raise_for_status()
result = response.json()
if result.get("code") == 0:
task_id = result.get("data", {}).get("task_id")
print(f"任务创建成功: {task_id}")
return task_id
else:
print(f"任务创建失败: {result.get('msg')}")
return None
except Exception as e:
print(f"创建任务失败: {e}")
import traceback
traceback.print_exc()
return None
def get_task_status(task_id):
"""
获取任务状态
:param task_id: 任务ID
:return: 任务状态
"""
print(f"查询任务状态: {task_id}")
api_url = f"{MINERU_API_URL}/extract/task/{task_id}"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {MINERU_TOKEN}",
}
try:
response = requests.get(api_url, headers=headers)
response.raise_for_status()
result = response.json()
if result.get("code") == 0:
status = result.get("data", {})
print(f"任务状态: {status.get('state')}")
return status
else:
print(f"查询状态失败: {result.get('msg')}")
return None
except Exception as e:
print(f"查询状态失败: {e}")
import traceback
traceback.print_exc()
return None
def poll_task_status(task_id, max_retries=60, interval=5):
"""
轮询任务状态
:param task_id: 任务ID
:param max_retries: 最大重试次数
:param interval: 轮询间隔(秒)
:return: 任务完成状态
"""
print(f"开始轮询任务状态: {task_id}")
for i in range(max_retries):
status = get_task_status(task_id)
if status:
state = status.get("state")
if state == "done":
print("任务完成!")
return status
elif state == "failed":
print(f"任务失败: {status.get('err_msg')}")
return None
elif state in ["pending", "running", "converting"]:
print(f"任务正在进行中 ({state}){interval}秒后重试...")
time.sleep(interval)
else:
print(f"未知状态: {state}")
time.sleep(interval)
else:
print(f"获取状态失败,{interval}秒后重试...")
time.sleep(interval)
print("轮询超时,任务可能仍在处理中")
return None
def download_and_extract_result(zip_url, output_dir):
"""
下载并提取解析结果
:param zip_url: 结果压缩包URL
:param output_dir: 输出目录
:return: 提取的文件列表
"""
print(f"开始下载解析结果: {zip_url}")
# 确保输出目录存在
os.makedirs(output_dir, exist_ok=True)
# 下载压缩包
zip_path = os.path.join(output_dir, "result.zip")
try:
response = requests.get(zip_url, stream=True)
response.raise_for_status()
with open(zip_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
print(f"压缩包下载成功: {zip_path}")
# 提取压缩包
extracted_files = []
with zipfile.ZipFile(zip_path, "r") as zip_ref:
zip_ref.extractall(output_dir)
print(f"压缩包提取成功: {output_dir}")
extracted_files = zip_ref.namelist()
# 删除压缩包
os.remove(zip_path)
print(f"删除临时压缩包: {zip_path}")
return extracted_files
except Exception as e:
print(f"下载或提取失败: {e}")
import traceback
traceback.print_exc()
return None
def get_markdown_result(extracted_files, output_dir):
"""
获取Markdown格式的解析结果
:param extracted_files: 提取的文件列表
:param output_dir: 输出目录
:return: Markdown内容
"""
print("查找Markdown格式的解析结果")
for file_name in extracted_files:
if file_name.endswith(".md"):
md_path = os.path.join(output_dir, file_name)
print(f"找到Markdown文件: {md_path}")
try:
with open(md_path, "r", encoding="utf-8") as f:
md_content = f.read()
print(f"Markdown文件读取成功长度: {len(md_content)} 字符")
return md_content
except Exception as e:
print(f"读取Markdown文件失败: {e}")
import traceback
traceback.print_exc()
return None
print("未找到Markdown格式的解析结果")
return None
def parse_local_file(file_path, model_version="vlm"):
"""
解析本地文件
:param file_path: 本地文件路径
:param model_version: 模型版本默认为vlm
:return: Markdown内容
"""
print(f"开始解析本地文件: {file_path}")
# 检查文件是否存在
if not os.path.exists(file_path):
print(f"文件不存在: {file_path}")
return None
# 检查文件大小
file_size = os.path.getsize(file_path)
if file_size > 200 * 1024 * 1024: # 200MB
print(f"文件大小超出限制: {file_size} bytes (最大200MB)")
return None
# 生成对象名称
timestamp = int(time.time())
file_name = os.path.basename(file_path)
object_name = f"mineru/{timestamp}_{file_name}"
# 上传文件到OSS
oss_url = upload_file_to_oss(
file_path,
OSS_BUCKET_NAME,
object_name,
OSS_ACCESS_KEY_ID,
OSS_ACCESS_KEY_SECRET,
OSS_ENDPOINT,
)
if not oss_url:
print("文件上传失败,无法继续解析")
return None
# 创建解析任务
task_id = create_parse_task(oss_url, model_version)
if not task_id:
print("任务创建失败,无法继续解析")
return None
# 轮询任务状态
task_status = poll_task_status(task_id)
if not task_status:
print("任务执行失败,无法获取解析结果")
return None
# 获取结果URL
zip_url = task_status.get("full_zip_url")
if not zip_url:
print("未找到解析结果URL")
return None
# 生成输出目录
output_dir = os.path.join(TEMP_DIR, task_id)
# 下载并提取结果
extracted_files = download_and_extract_result(zip_url, output_dir)
if not extracted_files:
print("下载或提取结果失败")
return None
# 获取Markdown结果
md_content = get_markdown_result(extracted_files, output_dir)
if not md_content:
print("未找到Markdown格式的解析结果")
return None
print("文件解析完成成功获取Markdown格式的结果")
return {"content": md_content, "output_dir": output_dir}
def main():
"""
主函数
"""
import sys
if len(sys.argv) != 2:
print("使用方法: python mineru_parser.py <本地文件路径>")
print("示例: python mineru_parser.py ./example.pdf")
sys.exit(1)
file_path = sys.argv[1]
# 解析文件
result = parse_local_file(file_path)
if result:
md_content = result["content"]
output_dir = result["output_dir"]
# 保存Markdown结果
output_file = os.path.join(TEMP_DIR, f"{os.path.basename(file_path)}.md")
with open(output_file, "w", encoding="utf-8") as f:
f.write(md_content)
# Print JSON result for caller to parse
print(
"JSON_RESULT:"
+ json.dumps(
{
"status": "success",
"markdown_file": os.path.abspath(output_file),
"output_dir": os.path.abspath(output_dir),
}
)
)
else:
print("文件解析失败")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,3 @@
requests
oss2
python-dotenv

105
tools/mineru/test_api.py Normal file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python
#coding=utf-8
import requests
from config import MINERU_API_URL, MINERU_TOKEN
def test_create_task():
"""
测试创建解析任务
"""
print("测试创建解析任务...")
api_url = f"{MINERU_API_URL}/extract/task"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {MINERU_TOKEN}"
}
# 使用官方示例 URL
data = {
"url": "https://cdn-mineru.openxlab.org.cn/demo/example.pdf",
"model_version": "vlm"
}
try:
response = requests.post(api_url, headers=headers, json=data)
print(f"状态码: {response.status_code}")
print(f"响应内容: {response.json()}")
if response.status_code == 200:
result = response.json()
if result.get("code") == 0:
task_id = result.get("data", {}).get("task_id")
print(f"任务创建成功任务ID: {task_id}")
return task_id
else:
print(f"任务创建失败: {result.get('msg')}")
return None
else:
print(f"请求失败,状态码: {response.status_code}")
return None
except Exception as e:
print(f"测试失败: {e}")
import traceback
traceback.print_exc()
return None
def test_get_task_status(task_id):
"""
测试获取任务状态
"""
if not task_id:
print("任务ID为空跳过测试")
return None
print(f"测试获取任务状态...")
api_url = f"{MINERU_API_URL}/extract/task/{task_id}"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {MINERU_TOKEN}"
}
try:
response = requests.get(api_url, headers=headers)
print(f"状态码: {response.status_code}")
print(f"响应内容: {response.json()}")
if response.status_code == 200:
result = response.json()
if result.get("code") == 0:
status = result.get("data", {})
print(f"任务状态获取成功,状态: {status.get('state')}")
return status
else:
print(f"获取状态失败: {result.get('msg')}")
return None
else:
print(f"请求失败,状态码: {response.status_code}")
return None
except Exception as e:
print(f"测试失败: {e}")
import traceback
traceback.print_exc()
return None
def main():
"""
主函数
"""
print("开始测试 Mineru API...")
# 测试创建任务
task_id = test_create_task()
# 测试获取任务状态
if task_id:
test_get_task_status(task_id)
print("API 测试完成")
if __name__ == "__main__":
main()

Binary file not shown.

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python
#coding=utf-8
import os
import oss2
from oss2 import Auth
def upload_file_to_oss(local_file_path, bucket_name, object_name, access_key_id, access_key_secret, endpoint='https://oss-cn-beijing.aliyuncs.com'):
"""
上传文件到OSS
:param local_file_path: 本地文件路径
:param bucket_name: OSS桶名称
:param object_name: OSS对象名称
:param access_key_id: 阿里云AccessKey ID
:param access_key_secret: 阿里云AccessKey Secret
:param endpoint: OSS端点默认为北京区域
:return: OSS文件URL
"""
print(f"开始上传文件到OSS: {local_file_path}")
try:
# 创建OSS客户端
auth = Auth(access_key_id, access_key_secret)
bucket = oss2.Bucket(auth, endpoint, bucket_name)
# 上传文件
bucket.put_object_from_file(object_name, local_file_path)
print(f"文件上传成功: {object_name}")
# 生成可访问的URL
# 注意这里生成的是临时URL有效期为3600秒
# 如果需要永久URL需要在OSS桶中设置文件为公共读
url = bucket.sign_url('GET', object_name, 3600)
print(f"生成OSS文件URL: {url}")
return url
except Exception as e:
print(f"文件上传失败: {e}")
import traceback
traceback.print_exc()
return None
def make_bucket_public(bucket_name, access_key_id, access_key_secret, endpoint='https://oss-cn-beijing.aliyuncs.com'):
"""
设置OSS桶为公共读
:param bucket_name: OSS桶名称
:param access_key_id: 阿里云AccessKey ID
:param access_key_secret: 阿里云AccessKey Secret
:param endpoint: OSS端点默认为北京区域
:return: 是否设置成功
"""
print(f"开始设置OSS桶为公共读: {bucket_name}")
try:
# 创建OSS客户端
auth = Auth(access_key_id, access_key_secret)
bucket = oss2.Bucket(auth, endpoint, bucket_name)
# 设置桶的访问策略为公共读
policy = """
{
"Version": "1",
"Statement": [
{
"Action": ["oss:GetObject"],
"Effect": "Allow",
"Principal": ["*"],
"Resource": ["acs:oss:*:*:{bucket_name}/*"],
"Condition": {}
}
]
}
""".format(bucket_name=bucket_name)
bucket.put_bucket_policy(policy)
print(f"OSS桶设置为公共读成功: {bucket_name}")
return True
except Exception as e:
print(f"OSS桶设置失败: {e}")
import traceback
traceback.print_exc()
return False

View File

@@ -0,0 +1,738 @@
# 拼合内容
生成时间: 2026-02-07 13:49:48
我现在再给大家介绍一下,这个三角洲行动中的这个全局光照方案。
来自天美第三工作室引擎技术组的魏东陈。
讲之前我还是先自我介绍一下我是15年加入的腾讯然后最早是参加了乐高无限的开发。
到了天美之后是先后参与了这个穿越火线手游然后绝地求生、全军出击还有这个CODM。
对我们现在的这个三角洲行动。
先看一下三角洲行动的这个总体画面效果。
![](20260207_134923_001.png)
我今天会按照这一个顺序我先简单介绍一下项目我会把常见的GI方案给罗列出来。
再根据三角洲行动这个项目,我们来看一下这个项目到底怎样进行一个技术选型,把这个完整的方案给讲一下。
项目现在已经累积了很多的DAU了。
他第一天开始,我们就希望尽量支持广泛的平台,然后大家一起爽玩。
![](20260207_134923_002.png)
所有地图都是单手通发的,就不会说只有一个地图就是在某一个特定平台上才发布,就不会这样。
每一个地图来说,就是要求玩家可见的这个区域,我们全都有全局光照的覆盖。
带来一些挑战大家看就是要求所见的区域全都有这个GI的覆盖。
![](20260207_134923_003.png)
C和这个手机这是性能差异很大的的平台。
你选这个GI方案肯定是有一些拉扯的那我们还是先把所有的GI方案都回顾一下然后我们再看看根据这个双端的体系到底该怎么选择。
![](20260207_134923_004.png)
是light map这是最简单和最传统最节省的方案。
还有就是像这个volume GI这个是在PC上可以稀疏化存储的这种光照体质体素数据它又有高精度它又显存控制得住是吧
![](20260207_134923_005.png)
像刺客信条、孤岛惊魂这类游戏都非常广泛的用了。
你像手游上这种就是有3D纹理规整3D纹理的这个volume DI。
![](20260207_134923_006.png)
它有3D纹理所以说它寻址采样就是比较GPU友好就节省性能。
还有就是像以鲁曼为代表的这个全动态的,它效果又好的,美术迭代效率又高。
你看这些方案里如果咱们比运行是性能的话如果从性能的角度讲那light map它肯定是最好的对吧
![](20260207_134923_007.png)
给你给一个2U然后就把这个数据把这个有微电子给采出来了然后GI的数据就得到了就非常的高效。
美术可以在局部增大这个light map的文字密度就得到很高的精度。
![](20260207_134923_008.png)
volume GI的话它这里是以这种稀疏化存储的GI为例子它这个数据存储获取就稍微麻烦一点。
就得在shader里去访问一个树形的体系结构然后根据这个position去查询到这个光照最后再还要根据法线去做一个解码。
可你可能又保存了这个SH或者是MND cube这种结构那这样的话它消耗当然是比lad mab要大一些但是还是可以接受了。
从我们经验看就是说你660显卡就跑一个1080P下跑一个16G那3毫秒以内是没有问题的。
对于全动态DI像是roman d这个是没有办法因为它迭代效率它是非常高的。
毕竟它是个全动态的效果这那的pass的数量就比前两种都多了一个数量级。
是比运行是性能但是咱们如果比项目成本的话light bank你虽然效率高但是你它的项目成本也非常高。
![](20260207_134923_009.png)
要制作这一堆2U而且这个麦氏和这个光照不解耦做起来非常搞人心态的事儿。
你一个match到底在哪些地方要分配更多的文素那迭代起来也绝对就是一个时间黑洞。
的地图,比如说像这个长空系统。
![](20260207_134923_010.png)
这全都铺上来的,不是你看你看这个框的数量,这根本就是一个不可能的事情。
相比之下这volunt这就舒服多了是吧
![](20260207_134923_011.png)
这没有2U这个烦恼你迈出和光照解耦运行师给定一个position之后立刻就可以有根据算法查到自己所需要的这个数据。
像这种大范围的这长工这种地图如果你用volume GI你甚至可以用一个自动化的流水线就每天进行不停的进行滚动烘焙。
只需要拉一下数据就可以得到你所需要的光照,对不对?
![](20260207_134923_012.png)
比如这个项目成本就是像luman还有这些全能快捷他把这个制作成本已经加压缩到接近为零没有任何等待对吧
在这样一条光谱上大家看越靠近左边,它这个运行时效率越高,但是制作成本也越高越靠近右边,它这个开发者越爽,迭代也越快,但是性能消耗也越大。
多方案里,三角洲行,我跟大家说就全都用了全都用了。
![](20260207_134923_013.png)
你大家听昨天的远光八四的分享还有你如果去看那个COD的分享大家都是在这一条光谱上都做出了个自己就是几乎全都用了。
认为这可以是一种可以说这叫一种趋同进化了。
还是看一下这个双端它对GI到底是怎样一个需求呢
![](20260207_134923_014.png)
C这边我们希望当然说是拔高效果那不用说但是你不论是lead mp还是vollied MGI尤其是vollied GI你这精度一旦上去之后那显存就会出现一个几何级别的增长对不对
PC这边所以你就要时刻提醒自己就是说如何控制显存。
C的match它也更加复杂。
PC match上搞这个light map那这个制作成本会更大。
就希望就更加希望往里面嚼,把它这个制作成本降低这个优势给发挥出来,所以说这里打了四颗星代表对他的一个期待。
这边大家可能想不到就是对于GI系统或者说对所有的系统首要的要求就是你要把这个包量给我降下来对吧
体保证手游玩家体验的一个非常重要的一环。
无脑的全都铺了个light map那包量就会过于庞大。
你这还有TOD的这么一个情况下手游的性能比PC弱很多所以你要控制好消耗。
你要注意大多数其实它就是普遍手游一个情况就是GPU瓶颈。
如果你有这个的方案的话你这个消耗就不要比这个live map增长太多。
我们对他的这样一个期望。
受限于性能的话手游的这个light map使用范围确实还是要更大一些。
然后我们这个选用原则就是说我们希望各个平台上都要优先保证运行效率。
![](20260207_134923_015.png)
不是说什么2K60FPS因为多人竞技你最好要上个120、130、100 44这种然后在此基础上尽量的提高这个制作速度。
多人体图端我们都用了light map加volume GI的这个组合方案。
TC上这个volume I用的就是更多一些。
战役的话因为没有PVP竞技的情况下就直接用鲁本拔高效果。
它这个迭代效率高,事实上能达到更好的一个品美术品质。
我们先看这个PCR这个light map我先说一下这个light map Baker这是我们自己研的一个ggs红莓器从CF手游就开始用了然后一直迭代一直用到三角洲行动现在已经是一个基于GPU的这样一个烘焙器。
![](20260207_134923_016.png)
map这个东西可以说让人真是又爱又恨它这个效率品质都很好。
它场景到了之后,离线制作的这个也是非常吞噬时间的一个东西。
又在希局部希望很拉高精度的地方就还是用了这个light map。
最大的这个长空地图只有这些标记为浅绿色的地方是用了light map。
light map上面只有只有人工光和天光的可见度。
map它主要是负责室内光照可以说这也可以让可以让各地图制作局部的各个切块之后各个美术同志他可以独立的进行烘焙它不受整体太阳光照方向变化调整的影响。
大地图使用lid map的它的主要限制就是制作效率。
![](20260207_134923_017.png)
如果像长虹那种地图你用了lid map之后全都用lid map那你可能就是这地图永远就发布不了尤其你还有这个TOD的情况下。
就来看一下PC上覆盖范围最大的这么一个部分就是它这个volume键这是我们的重点。
![](20260207_134923_018.png)
最大的好处就是大幅的提升了制作效率品质也能接进来的map。
从这个防漏光基础的数据元素然后存储、拟合、压缩最后到全世界方案的这样一个顺序来介绍一下这个GI的方案。
还是这里我就先得提一句如果你准备开始用volume MGI之后它就有一个非常隐私的陷阱。
![](20260207_134923_019.png)
就是经历那么几个项目你才能得出来的这么一个经验。
看这个函数图表横轴代表制作代价纵轴代表这个GI方案的综合效果。
效果就是指画面加运行效率综合起来这种效果大家看在这个点这是light map它有很高的制作代价但是它的综合效果也确实很好。
理想中的volume jr是什么
应该是大幅提升制作效率,对不对?
代价大幅下降,但是它的综合效果下降很小,这是理想中的王俊杰。
就非常容易进去一个陷阱什么的。
搞的这种就是你做出来之后确实和麦是解耦了,那程序觉得自己好厉害,然后美术由于工作负担下降,他也觉得很开心。
你别忘了这就是volume GI他自己也有像是漏光这种固有问题。
问题如果处理不好就集中,极易造成制作成本虽然下降了,但是画面也有一点下降。
有一点漏光瑕疵等等,或者说你画面没有下降,程序性能大幅下降。
比如说你运行时必须花一个大代价去解决这个漏光问题。
volume GI相对于这个light map来说大家发现没有它的总的性价比并没有大的变化。
制作团队在做这个东西的时候,它就各有各的爽点。
前面说了就是说对此对这个性价比提升就是感知不强。
最恐怖的时候就当这个测试回单的时候要么发现你这个东西效率不太好要么发现画面有一些什么瑕疵之类的然后产生很多很多bug的。
为了修复这些bug又得去各种hack解决反复折腾然后把这个volley GI的清低成本的优势给搞没了。
就是我见过一些项目是进入过这个陷阱的这也是经历了一些就是这样一些经验教训,然后才我们才开始特别重视这个事情。
所以我先提防漏光,就是防漏光。
![](20260207_134923_020.png)
volleyer之间从项目角度讲能否成功的一个关键因素。
我因为我们就要追求高性价比甚至我可就是漏光它是一个volume GI就常有的一个瑕疵。
这个体素的宽度比这个墙厚的时候,那漏光就可能产生你踩踩的踩到墙对面去,对不对?
什么时候这个漏光根本就无法预测的话那制作组来bug单就会非常多。
这种拉扯很很可能就把这个制作优势,这个制作成本低这个优势给抵消掉。
这个方案必须本身必须能斩钉截铁的告诉美术,美术同事就说你怎么制作才能完全避免漏光。
起来简单的话,那也是真简单,就是把这个体速精度给尽量的提高,但你就别超过性能的预算。
![](20260207_134923_021.png)
行动里面是0.25米。
告诉美术同事就是说所有的墙只要超过0.25米厚度超过0.25米,它就一定不漏光。
0.25米对于大多数几何体来说,这是一个可以接受的方案。
运行时三星应用插值的时候,你那你无论说都不可能踩到墙面后墙后面去,对不对?
这个体你你这个墙比体塑要厚那如果实在碰上必须小于0.25的比如说铁皮房咱们再想hack的办法。
你存在这样一个非常清晰的标准,然后就能从制作层面比规避大多数绝大多数漏光。
这件事你必须从项目第一天就开始做,否则那你只能运行时去搞个什么算法去防漏光了。
做一个什么世界空间的那个奔驰刀等等。
![](20260207_134923_022.png)
每一个提速来说,我们还是看一下的基础的数据元素是什么。
体系就存一个六个方向的irregular的这个amin cube其实就是半成明二以来非常经典的一个结构。
使命召使命召唤黑色行动系列也用了这个结构。
运行时是根据法线从这个六个方向里插值得到这个最终结果。
必须看一下就是这个0.25它是一个很高的密度,比较高的精度。
![](20260207_134923_023.png)
用这个规整的3D纹理去存的话这显存很快就炸了轻松给你上8个G16个G所以就需要一个稀疏化存储的方案就是越靠近mesh我才存储高精度的提速。
![](20260207_134923_024.png)
0.25米这么高精度的东西最好只贴着静态mesh版。
我们去读虚幻引擎自带的open VDB的存储代码的时候我们就参考它实现了一个比较高效的方案。
open VDB它其实也是这个胡迪尼的一个核心组件提渲染就是很核心的一个组件。
![](20260207_134923_025.png)
看就是这个数据块它横向大小为64乘64这是我们一个离线分块存储每一个数据块就是这么大这就是GI的数据。
每个数据块内部我按照以4米为精度存储规整的粗糙的体素元素。
个体数个头大它这个数量可以虽然它是一个规整的这么一个结构大家看这就是这些黄色的体素对于每一个4米的这个提速我给它各个维度除以4然后分裂产生64个这种1米宽度的提速就这些蓝色的提速。
![](20260207_134923_026.png)
这一次我就要求这些蓝色的这一米提速,你必须得靠保只保留那些靠近麦是足够近的,加个阈值。
![](20260207_134923_027.png)
这里是3米大家看远处的都舍弃掉了然后再来一次继续分裂这种一米的体素然后形成0.25米的体速。
![](20260207_134923_028.png)
这次再压缩阈值要求只保留距离面试表面很近的0.3米的体速,你看这基本就贴着卖是摆了一层。
我们就可以形成一个逻辑上的,就是从上到下的这样一个数据结构,一个树形结构。
![](20260207_134923_029.png)
其实是个六十四叉树,类似这个图。
我们手机节点确实是从4米开始的我们没有一个实际上的根节点。
给定一个word position之后我们怎么定位说这个word position到底对应到哪个0.25米高精度提速上呢?
还是我们去参考open ABDB实现了这样一个算法就是我们先把这个提速数给按照广度优先便利给便利一遍然后形成一个序列。
![](20260207_134923_030.png)
用二叉树做了一个示例。
一个word position之后怎么快速知道所需求的这个光照数据究竟在这个序列中哪个位置呢
在每一个数字节点中我们定义一个64 bit的exist mask就是一个bit mask。
表示出每个节点的64个子节点当中究竟哪些子节点是保留了的一代表保留零代表这个子节点距离max太远就给舍弃掉了。
对于这个bit mask实际上相当于一个小型的局部正方体它包含了规整的4乘4个一共有64个子节点是否存在的这么一个情况。
一个子节点只要给定相对于父节点的这个局部坐标就可以快速对应到究竟就自己究竟是bit mask里面究竟哪一个bit。
你得知道如果你这个子节点是保留了的,比如说是这个一对不对,那它是第几个子节点呢?
你只要数这个bit max从开头到你这个一共经历了多少个一。
正好C端里面有一条一个叫count base的指令一条指令就可以帮你做好这个事情。
节点里我再保存好这个节点在第一个子节点的下标比如说这是比如说就这是M再把count face的这个结果给加上来。
是2那你就可以得到M加2。
这样就是用很低的代价就可以获得这个子节点在这个数里面这个下标这里其实利用了一下这个BFS这个性质就是说一个节点的子节点它必定在这个序列中连续梦回大邑然后你最多重复三次这个过程直到访问或者miss。
按照这个顺序的这个序列这个顺序我们再把它对应的这个光照的这这个序列也给排布出来。
只要获得到任何一个position只要查到了自己在树中的这个下标就可以查到自己在这个光照序列中的下边然后直接取用这里的数据就可以了。
当然运行是你要去采样八次,因为你要做三星星差值。
因为有大量的这种硬件方面的指令的支持所以说这个访问速度其实它是很快的OK但是经过刚才这样的稀疏存储总体体积依然是比较大的那毕竟每个尺寸它有48个bit对不对
![](20260207_134923_031.png)
一个六个方向的一个EAM and cube那我们希望这个体积还是再小一点。
你相邻的体速大家看相邻体速光照这个数值是接近的那我们希望再做一点压缩的处理另外地图是支持TOD的对不对
个体数上可能存储更多的数据。
我们希望就是每个体数上存储的体积不要受总时段增长的影响。
为了你和压缩,我们还是要做点准备工作。
![](20260207_134923_032.png)
还是调用这个open ADB给这个场景沿着match表面生成一层pro作为这个支撑点数据这些probe的密度会远比那些提速要稀疏。
proof上给它烘焙一个48 bt的MN的cube。
如果有多个时段比如说一天有八个时段那你那你就烘焙8份就存储这八份儿都存储在这个数这个游戏包里。
probe你可以控制的很稀疏这些东西它上面的数据它不怕那个数据多。
希望每一个体数上就是它存储的这个数据量远小于原生的48倍数据量并且不随着时段增长而增长。
![](20260207_134923_033.png)
就可以用距离它最近的四个problem上的m and cube去做一个线性的组合把这个原生的结果给拟合出来。
这我其这就是我们希望让这个高纬度的向量上就是48倍的那些很很好的向量让这些稀疏的probe去存储。
这些密集的体素只需保存,只去组合它们就可以了。
每个密集的体系上就只需要存储四个index加四个float这样只有恒定的16个bit那就比48小多了。
这个相邻的提速的这个index也。
一样的那就很容易利用类似游程编码的方式进行进一步的压缩但这个就不能展开讲了就时间比较有限OK那令所有提速的这个权重还有就是所有的这些绿色节点发出来这些红色的这些边就他们所有的这权重还有令所有pro 5上的这些支撑点的这些数据都可以作为变量变化。
![](20260207_134923_034.png)
就希望调整这些所有的这些数据变化,然后让每一个体素上线性组合出来的这个数值与原生数值这个差尽量的小。
用的是差的平方的形式,因为它好求导,然后我们就希望把所有这些差的平方加起来,就这个平方和让它尽量的小,这就是一个全局优化的问题。
![](20260207_134923_035.png)
看似不好解决,但是这个函数无论是对于权重还是对于上的这个数值,都很容易把这个偏导数给写出来。
这就容易多了,对不对?
这函数本身它也就是一个对偶图形函数,它也没有什么病态结构。
就可以可以直接利用基于导数的这个梯度优化进行数值优化。
具体用的就是一个共轭梯度法,这个并没有什么不可想象的困难。
具体优化措施就实现了一个基于SMD和面向数据的共轭梯度优化器。
![](20260207_134923_036.png)
于这样一个数据块来说64乘6 14米单核心9毫秒就可以完成优化。
实际上生产的时候肯定多核心并行了,平均不到半秒就优化这样一个数据块。
优化这个数据块的时间远小于它烘焙器烘焙它的时间。
我们再看经过所有这些优化,大家可以看一下这个显存,这个图放的有点小了。
![](20260207_134923_037.png)
给大家说一下,就典型的对于刀锋这个图,它的数据的垂直高度还是比较深的。
volume GI的总显存是控制在200兆左右那这个量是可以接受的这一套说下来我希望大家注意一下我们的这个思路顺序就是优先考虑这网里面GI的这个方案的性价比而不然后一步步推导出说必须用0.25米提速,然后再才有后续所有这些效率优化。
不是说开发的时候觉得我怎么高大上了我怎么做了然后可能那你可能就掉进什么别的陷阱里面OK。
我们希望就是PC上GI的这个视距尤其是太阳光的这个间接光就能完整的覆盖地图。
![](20260207_134923_038.png)
这个图没有过头map就是我们刚才说streaming进来那些高精度的铁GGI数据大概也就覆盖这么远那肯定是覆盖不了全图的对不对
就希望以最好有那么一份数据常驻内存。
只有近景一个tell的GI那么大的大小但是它把全图的这个GI全都给cover住了这个该怎么搞呢
![](20260207_134923_039.png)
海岛这个图这个视距还会更远一点我们希望所有的视距下都有GI覆盖。
的方法肯定不行,对不对?
需要全场景覆盖的这个方案,我们还是用稀疏的提速数据存储,这还是刚才一样的算法。
![](20260207_134923_040.png)
这个提速个头巨大无比达到了8米是你你也可以调整的更大。
覆盖全场景,它的这个总数量也是不多的。
我们要求每个体素体体速里面存储的这个数据量还是必须和48倍是一数量级。
多两倍、三倍但你绝对不能说翻个十倍、20倍那是不行的那是不是就是我直接干脆存那个MM的Q不就完事了那是不是就可以了
这当然是可以,但是不够好。
给大家看一下大家看这个巨型提速这个8米这个大型提速里面如果只有一个数值的话那你在里面采样你不管怎么采的都采出来就是一个数值是吧
![](20260207_134923_041.png)
就存了一个,也不是不行,反正远处看去也就是糊的一团,但是总归是不够好。
就希望他最好能提供一个和近景接近的这么一个精度。
我们就希望大家看这个大cube里面我们就希望给定一个任何一个XYZ的位置这个XYZ是这个局部坐标我们就希望有一个比较紧凑的函数F我输入一个XYZ之后返回这个XYZ上面的这个光照那这个返回值还是比较精确。
看把这个建筑移除了之后这个函数F返回的是天光向上的这个可见度。
在我这个屋檐房子里的遮挡下,大家看里面是不是比较黑的,这个模拟的还是比较精确的。
如果这个大Q0就只有一个数值的话那你采出来就没有这种变化了。
到底是什么函数?
表达这个函数呢?
函数用的是这样一个小型的深度神经网络的宽度为四。
两个隐藏层的激活函数用的就是VKREOU。
体系内部用烘焙器去密集的烘焙,它内部的这个光照数值相当于是提供大量的训练数据。
给定大量XYZ之后这个ground choose的光照数值然后用它这些大量的数据去训练这个小型的神经网络OK。
这个网络里面其实存储量也就是几个矩阵的事儿。
这个小型神经网络它输入是xyzh然后隐藏层一共是两层。
输出是一个单一的一个数值就是一个luminance而不是RGB。
为了增大这个训练的精度。
的话只需要有两个矩阵乘法的计算量,它虽然它是神经网络,但是因为它很小,所以它并不会造成很大运行时的负担。
![](20260207_134923_042.png)
它在8米内又可以提供一个足够高的这么一个精度。
这个小型网络的函数表达大家看因为层数很小你也用不着什么重度横向pat talk去做什么自动求导。
函数小。
手动的把里面各个参数导出写出来,然后继续使用一个共轭梯度优化器,就可以把它给优化出来。
吧?
用py tok部署到蓝盾服务器上那种流水线上别自己再出点什么bug把那个流水线给搞挂了。
![](20260207_134923_043.png)
看这个图,我们从外面看就是这个神经网络对天光可见性的拟合。
是不是已经非常接近烘焙器的烘焙效果。
你从房子内部看,它当然还是漏光的。
![](20260207_134923_044.png)
你作为远景GI这个漏光是没有关系的。
远景GI都是从室外往从远处往室内看所以说这个光照你这个光照拟合它也是从一定是从更亮的地方往更暗的地方漏。
更亮的地方这个数值变化,它能造成那个优化器里面更大那个函数优化那个数值的惩罚。
这个算法一定是照顾光更加亮的地方,它才会从室内往室内漏。
它才作为远景点它没有问题。
我们说这我还是稍微解释一下这个小型神经网络里面的非线性就来自它上面这一堆lik IREOU。
IRELU大家知道就是max 0.2A乘以a max 0.2AA所以说它这个函数总体上输出一定是一个分段线性函数。
看即便是在这些内部的漏光结构上,它是不是也是一个折线的,这样有个多个折线组成的。
它就是用尝试用多个折线去拟合这个建筑的这个形状,这非常有点像是画素描。
有大家有没有画过素描你画素描的时候有一种画法就是只能就即便是对这个圆你也只能一笔一笔去画直画尽量多的直线去拟合这个圆儿当然了我们是不可没有尽量那么多的直线的我们的直线这个折线的数量就是VKR1U的数量。
这个拟合过程就是用画素描做了这样一个类比OK。
我们对比一下这是没有只只有streaming p streaming GI的这么一个范围还有这就是全图GI的这样一个范围。
![](20260207_134923_045.png)
K这海岛站这当然是更远的视距。
看一下这个就是同样一个vive下这个监狱的这样一个对比这个就非常明显了。
这就是全世界GI这样一个方案。
![](20260207_134923_046.png)
这个它有多大显存呢?
看这些都是UR set存储的这些全数据GI的显离线数据大小这些都是非压缩的数据这上面显示有多大存储显存就有多大。
看风风暴眼这么大它大概是22兆然后刀锋十一兆监狱是八兆那这个数据量是没有问题的。
数据量就是作为全图来说,这是一个很很合理这么一个数量,没有任何问题。
![](20260207_134923_047.png)
咱们再看一下这个地图这个攀升取得攀升地图的GPU消耗。
GPU性能消耗全都加起来就是远景加GI加上就是所有都加起来它是与pray pass接近。
这就不谈具体多少毫秒因为不同显卡是有差异的这张图上是3080显卡。
可以看到就是当base pass是0.85毫秒的时候它是0.35。
消耗其实更接近深度垂pass。
垂pass 0.32就说那这个性能接消耗的代价是可以接受的。
你你你用一个接近深度play pass这么性能把全部的GI都给做出来。
之前这个防漏光已经在制作阶段消灭了绝大多绝大多数运行时也没有任何昂贵的pass去做特殊处理漏光总显存可以控制在二百多兆这才算的是高性价比。
I在这些侠这在这些所有问题都没有后顾之忧之后大幅下降的制作成本它才有意义。
还是谈谈mobile这边总体方案还是LED mab加这个volume GI的这个组合。
![](20260207_134923_048.png)
volume MGI目前没有办法做的像PC覆盖的那么远了所以说mobile上这个light mab用的范围确实是更大的对吧
看这个面积是比较大的但是这个lde mab依然还是只有人工光加天光可见度那太阳光的间接光还是靠的这个volume。
可以看一下这个view下这个使用light map的这个物体就是这么多。
我们可以看一下就是不使用lima b加上这个volume GI的这个区别。
![](20260207_134923_049.png)
大家可以对比看一下。
大家注意这个billiam GI这个范围就只有这么远这是眼前这么64米再远都是全远景的平均数值。
![](20260207_134923_050.png)
配合着远景的有这个天光可见度这个lid map这个房屋的看起来还是OK的。
light这个mobile上这个volume它具体这个算法和PC就有比较大的差异了。
上面手游普遍情况就是它是GPU瓶颈所以你添加新算法你不能给GPU添加太多的负担。
![](20260207_134923_051.png)
让GPU去运行时访问一个提速数的话那就太耗费了。
手游的PCCPU倒是目前是经常什么八核心
绑定一个比较节能的小核心用它去来异步的组装的这个3D纹理那还是比较合适的。
去异步组装一个包围相机的3D纹理然后GPU里只要去采样一下就可以了就立刻把这个UV电池给拿出来对吧
不比烂的外部就是费多少。
赖外部是采一个2D你现在采一个3D的而已然后手游同样是要离线的去分块存储数据但是每一个数据块内部你不能说就是这么稀疏的把它给存储了。
![](20260207_134923_052.png)
虽然省爆量但是你去访问起诉数的话那个CPU一定是有大量的cache miss它就非常的CPU不友好。
也不能这样暴力的就把给它做一个规整的存储这样CCPU访问起来非常爽了但是它这个空间占用就会非常大就是非常的耗费包量。
别忘了说我们手游的一个很重要的要求就是节省包量。
![](20260207_134923_053.png)
我们就做一个折中就是每一个数据块内部就是分块存储每一个数据块内部把这个数据也做一个分段连续存储每个分段内部是一个规整的长方体网格相当于一个局部的小型3D纹理。
它只包围自己的局部空间然后实时组装包围这个三相机的这个3D纹理的时候就这个绿色的大框它就是那个包围相机的3D纹理。
它的时候把每个数据块对齐到3D纹理中自己对应的那个位置上然后把这个数据给填充到3D纹理里面去。
这个3D纹理肯定是要做各种scoring然后追求一个稳定的显示然后分层分蒸的上传到GPU上去。
你需要注意的是你可以多利用手机上这种特有的这种半精度浮点数的这个SMD指令它它就可以极大的加速这个组装的过程。
大家这就到了整个GI方案里代码量最大的地方了就是这个手游的防漏光。
大家注意这个3D门里精度是1米那你墙是无论如何都不可能做到1米厚的。
这个没有办法通过制作来解决,那怎么办呢?
就要定义一种叫做iteration of volume的这个体积就是简称IV。
IV是随着建筑一起做的你可以认为是一个简化的麦氏就是大体表达出这个建筑的形状。
IV其实是一个多面体了它有多个面组成。
的时候要求一个建筑可以由多个IV拼起来每一个IV必须是突兀多面体。
这个IV的这个房子的核心区域它是一个正方体那IV的每个面正好是那这个IV每个面正好是卡在墙里面的那比如这个面它代表了这堵墙我把它拉出来了看的明显一些。
就可以在离线根据IV的面片和这个体素的位置识别出能够导致漏光的那些体素然后把这些体素与这个面片给关联起来。
在运行时的时候就可以根据相机在这个面片的前与后这个关系来判断出出这些体素是不是在墙的另一边。
是的话就把这个体素的数值给fout掉。
看一下这个但是因为漏光的区域可能涉及到3D纹理里面各个区域而这个纹理你又不得不分针上传所以说你就不得不允许有一个滞后更新的这么一个现象。
在PVP对战的时候这个倒是没有人在意的这个实在是一个没有办法的事情。
防手游的防说起来简单其实它是一个巨大的代码量它可能是整个所有系统里代码量最大的地方但其实没有关系大家看我头发然后手游的这个数据压缩和端游是类似的只不过它这个pro不会稀疏很多。
刚才不说了吗?
靠近麦氏的地方每个数据库就是靠近麦氏的地方有这个数据块cover远离mesh的地方那如果这个数据是什么呢
直接采用这个地方如果需要数据的话就直接采用距离操最近的这个pro上的光照。
运行时还去动态生成了一个distant back field指向每一个位置距离它最近的那个pro OK。
看一下重点关注的爆量volume加上这个报量最大这个图GI常规硒鼓达到了334兆。
如果这个全都用lid map来做的这个PI的报量会翻倍五倍不止。
这么大的地图你全用LDMAB做那肯定也不太可能的事就是工期就不允许。
这里就稍微总结一下就是手机上的volume GI仍然是大幅的降低了制作的代价。
纯用来的mab相比这个包量也大幅下降都达这都达到了目的。
光我们用了IV的方式也没有造成什么太多的拉扯。
这个太阳光的棒子相对于来的map相比确实是范围小了就眼前64米这么远。
手游上也是确实足够了总体性价比还是OK的没有问题。
然后我们看看一下这个远景的方案。
远景的话我刚才说了就是远景就一个平均值替代如果再高配一点机器用一个纵向俯拍的2d text。
有一个非常重要的是我们大量的使用了SMD。
UE对SMD的这个封装也是非常完善的就是直接很直观的用这个函数名字你就不用再去写SMD的时候对应的一个一个那种鬼画符。
对,刚才这是多人的部分,咱们再看看单人战役的部分,这就是这个黑鹰坠落。
其实能说的并不多,就是黑鹰黑鹰坠落本身开发的时间非常短。
作为罗那罗美作为不需要让人进行任何等待,这个实时也其实美术对它进行调节,能立即看到光照效果。
他那个反复调整的效率,它这个光照品质是更高的。
K然后黑鹰的lemon没有做二次开发就是优于我的原生lemon。
是用的是软光追没,没有开硬光追。
试验的情况看就是3080级别以上显卡的时候用硬光追的这个路面性能才会超过这个软光追所以这里还是稍微照顾了一下显卡的性能OK。
咱们今天就做一下这个总结。
就是这个live map需要做取舍你需要满你只要能满足性能和这个效果的时候你可以更多的使用这个William GI去提升制作效率。
如果比如说你从0.25硬拉到0.1巷light my普遍这个文速精度是0.1。
从0.25拉到0.1之后,其实这个边际效应是有点递减的。
你可以多采用一下这个volunt来提升一下效率非常关键的就是你做好防漏光是volume GI跳出性价比陷阱的关键。
你说你做volume GI的话你应该优先考虑防漏光然后再想后边那些其他算法OK然后PC上我们多关注一下显存的控制然后包边上要多注关注包量然后还有这个就是像罗曼能带来巨大的效率制作效率的提升。
再过几年等玩家最低显卡都已经到3080级别的时候roman一定就是在市面上非常最普遍的一个方案。
K这就是我今天的演讲谢谢大家我的车谢谢大家。

View File

@@ -0,0 +1,738 @@
# 拼合内容
生成时间: 2026-02-07 13:57:40
然后我现在再给大家介绍一下,这个三角洲行动中的这个全局光照方案。
我是来自天美第三工作室引擎技术组的魏东陈。
然后讲之前我还是先自我介绍一下我是15年加入的腾讯然后最早是参加了乐高无限的开发。
然后到了天美之后是先后参与了这个穿越火线手游然后绝地求生、全军出击还有这个CODM。
就是对我们现在的这个三角洲行动。
我们先看一下三角洲行动的这个总体画面效果。
![](20260207_135715_001.png)
OK, 我今天会按照这一个顺序我先简单介绍一下项目我会把常见的GI方案给罗列出来。
我们再根据三角洲行动这个项目,我们来看一下这个项目到底怎样进行一个技术选型,把这个完整的方案给讲一下。
这个项目现在已经累积了很多的DAU了。
从他第一天开始,我们就希望尽量支持广泛的平台,然后大家一起爽玩。
![](20260207_135715_002.png)
他所有地图都是单手通发的,就不会说只有一个地图就是在某一个特定平台上才发布,就不会这样。
对于每一个地图来说,就是要求玩家可见的这个区域,我们全都有全局光照的覆盖。
这就带来一些挑战大家看就是要求所见的区域全都有这个GI的覆盖。
![](20260207_135715_003.png)
PC和这个手机这是性能差异很大的的平台。
这就是说你选这个GI方案肯定是有一些拉扯的那我们还是先把所有的GI方案都回顾一下然后我们再看看根据这个双端的体系到底该怎么选择。
![](20260207_135715_004.png)
首先是light map这是最简单和最传统最节省的方案。
然后还有就是像这个volume GI这个是在PC上可以稀疏化存储的这种光照体质体素数据它又有高精度它又显存控制得住是吧
![](20260207_135715_005.png)
现在像刺客信条、孤岛惊魂这类游戏都非常广泛的用了。
或者你像手游上这种就是有3D纹理规整3D纹理的这个volume DI。
![](20260207_135715_006.png)
因为它有3D纹理所以说它寻址采样就是比较GPU友好就节省性能。
然后还有就是像以鲁曼为代表的这个全动态的,它效果又好的,美术迭代效率又高。
那你看这些方案里如果咱们比运行是性能的话如果从性能的角度讲那light map它肯定是最好的对吧
![](20260207_135715_007.png)
他给你给一个2U然后就把这个数据把这个有微电子给采出来了然后GI的数据就得到了就非常的高效。
而且美术可以在局部增大这个light map的文字密度就得到很高的精度。
![](20260207_135715_008.png)
然后volume GI的话它这里是以这种稀疏化存储的GI为例子它这个数据存储获取就稍微麻烦一点。
你就得在shader里去访问一个树形的体系结构然后根据这个position去查询到这个光照最后再还要根据法线去做一个解码。
因为你可你可能又保存了这个SH或者是MND cube这种结构那这样的话它消耗当然是比lad mab要大一些但是还是可以接受了。
就从我们经验看就是说你660显卡就跑一个1080P下跑一个16G那3毫秒以内是没有问题的。
然后对于全动态DI像是roman d这个是没有办法因为它迭代效率它是非常高的。
但是毕竟它是个全动态的效果这那的pass的数量就比前两种都多了一个数量级。
刚才是比运行是性能但是咱们如果比项目成本的话light bank你虽然效率高但是你它的项目成本也非常高。
![](20260207_135715_009.png)
你要制作这一堆2U而且这个麦氏和这个光照不解耦做起来非常搞人心态的事儿。
而且你一个match到底在哪些地方要分配更多的文素那迭代起来也绝对就是一个时间黑洞。
更大的地图,比如说像这个长空系统。
![](20260207_135715_010.png)
如果这全都铺上来的,不是你看你看这个框的数量,这根本就是一个不可能的事情。
那相比之下这volunt这就舒服多了是吧
![](20260207_135715_011.png)
你这没有2U这个烦恼你迈出和光照解耦运行师给定一个position之后立刻就可以有根据算法查到自己所需要的这个数据。
尤其像这种大范围的这长工这种地图如果你用volume GI你甚至可以用一个自动化的流水线就每天进行不停的进行滚动烘焙。
美术只需要拉一下数据就可以得到你所需要的光照,对不对?
![](20260207_135715_012.png)
再比如这个项目成本就是像luman还有这些全能快捷他把这个制作成本已经加压缩到接近为零没有任何等待对吧
那在这样一条光谱上大家看越靠近左边,它这个运行时效率越高,但是制作成本也越高越靠近右边,它这个开发者越爽,迭代也越快,但是性能消耗也越大。
这么多方案里,三角洲行,我跟大家说就全都用了全都用了。
![](20260207_135715_013.png)
包括你大家听昨天的远光八四的分享还有你如果去看那个COD的分享大家都是在这一条光谱上都做出了个自己就是几乎全都用了。
我认为这可以是一种可以说这叫一种趋同进化了。
我们还是看一下这个双端它对GI到底是怎样一个需求呢
![](20260207_135715_014.png)
PC这边我们希望当然说是拔高效果那不用说但是你不论是lead mp还是vollied MGI尤其是vollied GI你这精度一旦上去之后那显存就会出现一个几何级别的增长对不对
那PC这边所以你就要时刻提醒自己就是说如何控制显存。
PC的match它也更加复杂。
如果你在PC match上搞这个light map那这个制作成本会更大。
所以就希望就更加希望往里面嚼,把它这个制作成本降低这个优势给发挥出来,所以说这里打了四颗星代表对他的一个期待。
手游这边大家可能想不到就是对于GI系统或者说对所有的系统首要的要求就是你要把这个包量给我降下来对吧
这是体保证手游玩家体验的一个非常重要的一环。
如果你无脑的全都铺了个light map那包量就会过于庞大。
尤其你这还有TOD的这么一个情况下手游的性能比PC弱很多所以你要控制好消耗。
现在你要注意大多数其实它就是普遍手游一个情况就是GPU瓶颈。
所以说如果你有这个的方案的话你这个消耗就不要比这个live map增长太多。
这就是我们对他的这样一个期望。
当然受限于性能的话手游的这个light map使用范围确实还是要更大一些。
OK然后我们这个选用原则就是说我们希望各个平台上都要优先保证运行效率。
![](20260207_135715_015.png)
这不是说什么2K60FPS因为多人竞技你最好要上个120、130、100 44这种然后在此基础上尽量的提高这个制作速度。
所有的多人体图端我们都用了light map加volume GI的这个组合方案。
当然TC上这个volume I用的就是更多一些。
单人战役的话因为没有PVP竞技的情况下就直接用鲁本拔高效果。
因为它这个迭代效率高,事实上能达到更好的一个品美术品质。
OK我们先看这个PCR这个light map我先说一下这个light map Baker这是我们自己研的一个ggs红莓器从CF手游就开始用了然后一直迭代一直用到三角洲行动现在已经是一个基于GPU的这样一个烘焙器。
![](20260207_135715_016.png)
Line map这个东西可以说让人真是又爱又恨它这个效率品质都很好。
但是它场景到了之后,离线制作的这个也是非常吞噬时间的一个东西。
我们我们又在希局部希望很拉高精度的地方就还是用了这个light map。
面积最大的这个长空地图只有这些标记为浅绿色的地方是用了light map。
这个light map上面只有只有人工光和天光的可见度。
Light map它主要是负责室内光照可以说这也可以让可以让各地图制作局部的各个切块之后各个美术同志他可以独立的进行烘焙它不受整体太阳光照方向变化调整的影响。
这个大地图使用lid map的它的主要限制就是制作效率。
![](20260207_135715_017.png)
你如果像长虹那种地图你用了lid map之后全都用lid map那你可能就是这地图永远就发布不了尤其你还有这个TOD的情况下。
我们就来看一下PC上覆盖范围最大的这么一个部分就是它这个volume键这是我们的重点。
![](20260207_135715_018.png)
它最大的好处就是大幅的提升了制作效率品质也能接进来的map。
我会从这个防漏光基础的数据元素然后存储、拟合、压缩最后到全世界方案的这样一个顺序来介绍一下这个GI的方案。
我们还是这里我就先得提一句如果你准备开始用volume MGI之后它就有一个非常隐私的陷阱。
![](20260207_135715_019.png)
这是就是经历那么几个项目你才能得出来的这么一个经验。
大家看这个函数图表横轴代表制作代价纵轴代表这个GI方案的综合效果。
综合效果就是指画面加运行效率综合起来这种效果大家看在这个点这是light map它有很高的制作代价但是它的综合效果也确实很好。
那理想中的volume jr是什么
你应该是大幅提升制作效率,对不对?
然后代价大幅下降,但是它的综合效果下降很小,这是理想中的王俊杰。
但是就非常容易进去一个陷阱什么的。
就是你搞的这种就是你做出来之后确实和麦是解耦了,那程序觉得自己好厉害,然后美术由于工作负担下降,他也觉得很开心。
但是你别忘了这就是volume GI他自己也有像是漏光这种固有问题。
这个问题如果处理不好就集中,极易造成制作成本虽然下降了,但是画面也有一点下降。
比如说有一点漏光瑕疵等等,或者说你画面没有下降,程序性能大幅下降。
就比如说你运行时必须花一个大代价去解决这个漏光问题。
这种volume GI相对于这个light map来说大家发现没有它的总的性价比并没有大的变化。
但是制作团队在做这个东西的时候,它就各有各的爽点。
我前面说了就是说对此对这个性价比提升就是感知不强。
然后最恐怖的时候就当这个测试回单的时候要么发现你这个东西效率不太好要么发现画面有一些什么瑕疵之类的然后产生很多很多bug的。
然后为了修复这些bug又得去各种hack解决反复折腾然后把这个volley GI的清低成本的优势给搞没了。
说实话就是我见过一些项目是进入过这个陷阱的这也是经历了一些就是这样一些经验教训,然后才我们才开始特别重视这个事情。
这里所以我先提防漏光,就是防漏光。
![](20260207_135715_020.png)
其实volleyer之间从项目角度讲能否成功的一个关键因素。
所以说我因为我们就要追求高性价比甚至我可就是漏光它是一个volume GI就常有的一个瑕疵。
当这个体素的宽度比这个墙厚的时候,那漏光就可能产生你踩踩的踩到墙对面去,对不对?
如果什么时候这个漏光根本就无法预测的话那制作组来bug单就会非常多。
那这种拉扯很很可能就把这个制作优势,这个制作成本低这个优势给抵消掉。
所以说这个方案必须本身必须能斩钉截铁的告诉美术,美术同事就说你怎么制作才能完全避免漏光。
如果说起来简单的话,那也是真简单,就是把这个体速精度给尽量的提高,但你就别超过性能的预算。
![](20260207_135715_021.png)
三角洲行动里面是0.25米。
然后告诉美术同事就是说所有的墙只要超过0.25米厚度超过0.25米,它就一定不漏光。
那0.25米对于大多数几何体来说,这是一个可以接受的方案。
那运行时三星应用插值的时候,你那你无论说都不可能踩到墙面后墙后面去,对不对?
因为你这个体你你这个墙比体塑要厚那如果实在碰上必须小于0.25的比如说铁皮房咱们再想hack的办法。
但是你存在这样一个非常清晰的标准,然后就能从制作层面比规避大多数绝大多数漏光。
但这件事你必须从项目第一天就开始做,否则那你只能运行时去搞个什么算法去防漏光了。
比如说做一个什么世界空间的那个奔驰刀等等。
![](20260207_135715_022.png)
对于每一个提速来说,我们还是看一下的基础的数据元素是什么。
每一个体系就存一个六个方向的irregular的这个amin cube其实就是半成明二以来非常经典的一个结构。
像使命召使命召唤黑色行动系列也用了这个结构。
然后运行时是根据法线从这个六个方向里插值得到这个最终结果。
我们必须看一下就是这个0.25它是一个很高的密度,比较高的精度。
![](20260207_135715_023.png)
如果你用这个规整的3D纹理去存的话这显存很快就炸了轻松给你上8个G16个G所以就需要一个稀疏化存储的方案就是越靠近mesh我才存储高精度的提速。
![](20260207_135715_024.png)
那0.25米这么高精度的东西最好只贴着静态mesh版。
这里我们去读虚幻引擎自带的open VDB的存储代码的时候我们就参考它实现了一个比较高效的方案。
那open VDB它其实也是这个胡迪尼的一个核心组件提渲染就是很核心的一个组件。
![](20260207_135715_025.png)
大家看就是这个数据块它横向大小为64乘64这是我们一个离线分块存储每一个数据块就是这么大这就是GI的数据。
然后每个数据块内部我按照以4米为精度存储规整的粗糙的体素元素。
这个体数个头大它这个数量可以虽然它是一个规整的这么一个结构大家看这就是这些黄色的体素对于每一个4米的这个提速我给它各个维度除以4然后分裂产生64个这种1米宽度的提速就这些蓝色的提速。
![](20260207_135715_026.png)
但是这一次我就要求这些蓝色的这一米提速,你必须得靠保只保留那些靠近麦是足够近的,加个阈值。
![](20260207_135715_027.png)
比如说这里是3米大家看远处的都舍弃掉了然后再来一次继续分裂这种一米的体素然后形成0.25米的体速。
![](20260207_135715_028.png)
但这次再压缩阈值要求只保留距离面试表面很近的0.3米的体速,你看这基本就贴着卖是摆了一层。
这样我们就可以形成一个逻辑上的,就是从上到下的这样一个数据结构,一个树形结构。
![](20260207_135715_029.png)
它其实是个六十四叉树,类似这个图。
我只是我们手机节点确实是从4米开始的我们没有一个实际上的根节点。
当我们给定一个word position之后我们怎么定位说这个word position到底对应到哪个0.25米高精度提速上呢?
这个还是我们去参考open ABDB实现了这样一个算法就是我们先把这个提速数给按照广度优先便利给便利一遍然后形成一个序列。
![](20260207_135715_030.png)
这里用二叉树做了一个示例。
给定一个word position之后怎么快速知道所需求的这个光照数据究竟在这个序列中哪个位置呢
它在每一个数字节点中我们定义一个64 bit的exist mask就是一个bit mask。
它表示出每个节点的64个子节点当中究竟哪些子节点是保留了的一代表保留零代表这个子节点距离max太远就给舍弃掉了。
至于对于这个bit mask实际上相当于一个小型的局部正方体它包含了规整的4乘4个一共有64个子节点是否存在的这么一个情况。
任何一个子节点只要给定相对于父节点的这个局部坐标就可以快速对应到究竟就自己究竟是bit mask里面究竟哪一个bit。
然后你得知道如果你这个子节点是保留了的,比如说是这个一对不对,那它是第几个子节点呢?
其实你只要数这个bit max从开头到你这个一共经历了多少个一。
那正好C端里面有一条一个叫count base的指令一条指令就可以帮你做好这个事情。
每个节点里我再保存好这个节点在第一个子节点的下标比如说这是比如说就这是M再把count face的这个结果给加上来。
比如说是2那你就可以得到M加2。
那这样就是用很低的代价就可以获得这个子节点在这个数里面这个下标这里其实利用了一下这个BFS这个性质就是说一个节点的子节点它必定在这个序列中连续梦回大邑然后你最多重复三次这个过程直到访问或者miss。
然后按照这个顺序的这个序列这个顺序我们再把它对应的这个光照的这这个序列也给排布出来。
那你只要获得到任何一个position只要查到了自己在树中的这个下标就可以查到自己在这个光照序列中的下边然后直接取用这里的数据就可以了。
然后当然运行是你要去采样八次,因为你要做三星星差值。
但是因为有大量的这种硬件方面的指令的支持所以说这个访问速度其实它是很快的OK但是经过刚才这样的稀疏存储总体体积依然是比较大的那毕竟每个尺寸它有48个bit对不对
![](20260207_135715_031.png)
它是一个六个方向的一个EAM and cube那我们希望这个体积还是再小一点。
毕竟你相邻的体速大家看相邻体速光照这个数值是接近的那我们希望再做一点压缩的处理另外地图是支持TOD的对不对
那每个体数上可能存储更多的数据。
总之我们希望就是每个体数上存储的体积不要受总时段增长的影响。
然后为了你和压缩,我们还是要做点准备工作。
![](20260207_135715_032.png)
首先还是调用这个open ADB给这个场景沿着match表面生成一层pro作为这个支撑点数据这些probe的密度会远比那些提速要稀疏。
每一个proof上给它烘焙一个48 bt的MN的cube。
当然如果有多个时段比如说一天有八个时段那你那你就烘焙8份就存储这八份儿都存储在这个数这个游戏包里。
但是probe你可以控制的很稀疏这些东西它上面的数据它不怕那个数据多。
我们希望每一个体数上就是它存储的这个数据量远小于原生的48倍数据量并且不随着时段增长而增长。
![](20260207_135715_033.png)
因此就可以用距离它最近的四个problem上的m and cube去做一个线性的组合把这个原生的结果给拟合出来。
那这我其这就是我们希望让这个高纬度的向量上就是48倍的那些很很好的向量让这些稀疏的probe去存储。
而这些密集的体素只需保存,只去组合它们就可以了。
所以说每个密集的体系上就只需要存储四个index加四个float这样只有恒定的16个bit那就比48小多了。
而且这个相邻的提速的这个index也。
是一样的那就很容易利用类似游程编码的方式进行进一步的压缩但这个就不能展开讲了就时间比较有限OK那令所有提速的这个权重还有就是所有的这些绿色节点发出来这些红色的这些边就他们所有的这权重还有令所有pro 5上的这些支撑点的这些数据都可以作为变量变化。
![](20260207_135715_034.png)
我们就希望调整这些所有的这些数据变化,然后让每一个体素上线性组合出来的这个数值与原生数值这个差尽量的小。
这里用的是差的平方的形式,因为它好求导,然后我们就希望把所有这些差的平方加起来,就这个平方和让它尽量的小,这就是一个全局优化的问题。
![](20260207_135715_035.png)
这看似不好解决,但是这个函数无论是对于权重还是对于上的这个数值,都很容易把这个偏导数给写出来。
那这就容易多了,对不对?
你看这函数本身它也就是一个对偶图形函数,它也没有什么病态结构。
所以就可以可以直接利用基于导数的这个梯度优化进行数值优化。
这里具体用的就是一个共轭梯度法,这个并没有什么不可想象的困难。
然后具体优化措施就实现了一个基于SMD和面向数据的共轭梯度优化器。
![](20260207_135715_036.png)
对应于这样一个数据块来说64乘6 14米单核心9毫秒就可以完成优化。
那实际上生产的时候肯定多核心并行了,平均不到半秒就优化这样一个数据块。
实际上优化这个数据块的时间远小于它烘焙器烘焙它的时间。
然后我们再看经过所有这些优化,大家可以看一下这个显存,这个图放的有点小了。
![](20260207_135715_037.png)
我给大家说一下,就典型的对于刀锋这个图,它的数据的垂直高度还是比较深的。
但是volume GI的总显存是控制在200兆左右那这个量是可以接受的这一套说下来我希望大家注意一下我们的这个思路顺序就是优先考虑这网里面GI的这个方案的性价比而不然后一步步推导出说必须用0.25米提速,然后再才有后续所有这些效率优化。
而不是说开发的时候觉得我怎么高大上了我怎么做了然后可能那你可能就掉进什么别的陷阱里面OK。
但是我们希望就是PC上GI的这个视距尤其是太阳光的这个间接光就能完整的覆盖地图。
![](20260207_135715_038.png)
注意这个图没有过头map就是我们刚才说streaming进来那些高精度的铁GGI数据大概也就覆盖这么远那肯定是覆盖不了全图的对不对
我们就希望以最好有那么一份数据常驻内存。
它只有近景一个tell的GI那么大的大小但是它把全图的这个GI全都给cover住了这个该怎么搞呢
![](20260207_135715_039.png)
像海岛这个图这个视距还会更远一点我们希望所有的视距下都有GI覆盖。
刚才的方法肯定不行,对不对?
这个需要全场景覆盖的这个方案,我们还是用稀疏的提速数据存储,这还是刚才一样的算法。
![](20260207_135715_040.png)
但这个提速个头巨大无比达到了8米是你你也可以调整的更大。
即使覆盖全场景,它的这个总数量也是不多的。
然后我们要求每个体素体体速里面存储的这个数据量还是必须和48倍是一数量级。
你可以多两倍、三倍但你绝对不能说翻个十倍、20倍那是不行的那是不是就是我直接干脆存那个MM的Q不就完事了那是不是就可以了
但是这当然是可以,但是不够好。
我给大家看一下大家看这个巨型提速这个8米这个大型提速里面如果只有一个数值的话那你在里面采样你不管怎么采的都采出来就是一个数值是吧
![](20260207_135715_041.png)
因为你就存了一个,也不是不行,反正远处看去也就是糊的一团,但是总归是不够好。
我们就希望他最好能提供一个和近景接近的这么一个精度。
然后我们就希望大家看这个大cube里面我们就希望给定一个任何一个XYZ的位置这个XYZ是这个局部坐标我们就希望有一个比较紧凑的函数F我输入一个XYZ之后返回这个XYZ上面的这个光照那这个返回值还是比较精确。
大家看把这个建筑移除了之后这个函数F返回的是天光向上的这个可见度。
那么在我这个屋檐房子里的遮挡下,大家看里面是不是比较黑的,这个模拟的还是比较精确的。
但如果这个大Q0就只有一个数值的话那你采出来就没有这种变化了。
那到底是什么函数?
怎么表达这个函数呢?
这个函数用的是这样一个小型的深度神经网络的宽度为四。
有两个隐藏层的激活函数用的就是VKREOU。
这个体系内部用烘焙器去密集的烘焙,它内部的这个光照数值相当于是提供大量的训练数据。
就是给定大量XYZ之后这个ground choose的光照数值然后用它这些大量的数据去训练这个小型的神经网络OK。
然后这个网络里面其实存储量也就是几个矩阵的事儿。
然后这个小型神经网络它输入是xyzh然后隐藏层一共是两层。
然后输出是一个单一的一个数值就是一个luminance而不是RGB。
这就是为了增大这个训练的精度。
推理的话只需要有两个矩阵乘法的计算量,它虽然它是神经网络,但是因为它很小,所以它并不会造成很大运行时的负担。
![](20260207_135715_042.png)
但它在8米内又可以提供一个足够高的这么一个精度。
然后这个小型网络的函数表达大家看因为层数很小你也用不着什么重度横向pat talk去做什么自动求导。
因为函数小。
可以手动的把里面各个参数导出写出来,然后继续使用一个共轭梯度优化器,就可以把它给优化出来。
是吧?
你用py tok部署到蓝盾服务器上那种流水线上别自己再出点什么bug把那个流水线给搞挂了。
![](20260207_135715_043.png)
大家看这个图,我们从外面看就是这个神经网络对天光可见性的拟合。
这个是不是已经非常接近烘焙器的烘焙效果。
但是你从房子内部看,它当然还是漏光的。
![](20260207_135715_044.png)
但是你作为远景GI这个漏光是没有关系的。
因为你远景GI都是从室外往从远处往室内看所以说这个光照你这个光照拟合它也是从一定是从更亮的地方往更暗的地方漏。
因为你更亮的地方这个数值变化,它能造成那个优化器里面更大那个函数优化那个数值的惩罚。
所以说这个算法一定是照顾光更加亮的地方,它才会从室内往室内漏。
所以说它才作为远景点它没有问题。
那我们说这我还是稍微解释一下这个小型神经网络里面的非线性就来自它上面这一堆lik IREOU。
Lik IRELU大家知道就是max 0.2A乘以a max 0.2AA所以说它这个函数总体上输出一定是一个分段线性函数。
大家看即便是在这些内部的漏光结构上,它是不是也是一个折线的,这样有个多个折线组成的。
所以说它就是用尝试用多个折线去拟合这个建筑的这个形状,这非常有点像是画素描。
我不知道有大家有没有画过素描你画素描的时候有一种画法就是只能就即便是对这个圆你也只能一笔一笔去画直画尽量多的直线去拟合这个圆儿当然了我们是不可没有尽量那么多的直线的我们的直线这个折线的数量就是VKR1U的数量。
所以说这个拟合过程就是用画素描做了这样一个类比OK。
然后我们对比一下这是没有只只有streaming p streaming GI的这么一个范围还有这就是全图GI的这样一个范围。
![](20260207_135715_045.png)
OK这海岛站这当然是更远的视距。
我们看一下这个就是同样一个vive下这个监狱的这样一个对比这个就非常明显了。
OK这就是全世界GI这样一个方案。
![](20260207_135715_046.png)
那这个它有多大显存呢?
大家看这些都是UR set存储的这些全数据GI的显离线数据大小这些都是非压缩的数据这上面显示有多大存储显存就有多大。
大家看风风暴眼这么大它大概是22兆然后刀锋十一兆监狱是八兆那这个数据量是没有问题的。
这个数据量就是作为全图来说,这是一个很很合理这么一个数量,没有任何问题。
![](20260207_135715_047.png)
然后咱们再看一下这个地图这个攀升取得攀升地图的GPU消耗。
总体GPU性能消耗全都加起来就是远景加GI加上就是所有都加起来它是与pray pass接近。
我这就不谈具体多少毫秒因为不同显卡是有差异的这张图上是3080显卡。
这个可以看到就是当base pass是0.85毫秒的时候它是0.35。
它的消耗其实更接近深度垂pass。
深度垂pass 0.32就说那这个性能接消耗的代价是可以接受的。
就是你你你用一个接近深度play pass这么性能把全部的GI都给做出来。
再加上之前这个防漏光已经在制作阶段消灭了绝大多绝大多数运行时也没有任何昂贵的pass去做特殊处理漏光总显存可以控制在二百多兆这才算的是高性价比。
GI在这些侠这在这些所有问题都没有后顾之忧之后大幅下降的制作成本它才有意义。
我们还是谈谈mobile这边总体方案还是LED mab加这个volume GI的这个组合。
![](20260207_135715_048.png)
由于volume MGI目前没有办法做的像PC覆盖的那么远了所以说mobile上这个light mab用的范围确实是更大的对吧
大家看这个面积是比较大的但是这个lde mab依然还是只有人工光加天光可见度那太阳光的间接光还是靠的这个volume。
大家可以看一下这个view下这个使用light map的这个物体就是这么多。
然后我们可以看一下就是不使用lima b加上这个volume GI的这个区别。
![](20260207_135715_049.png)
对,大家可以对比看一下。
但是大家注意这个billiam GI这个范围就只有这么远这是眼前这么64米再远都是全远景的平均数值。
![](20260207_135715_050.png)
但是配合着远景的有这个天光可见度这个lid map这个房屋的看起来还是OK的。
关于light这个mobile上这个volume它具体这个算法和PC就有比较大的差异了。
Mobile上面手游普遍情况就是它是GPU瓶颈所以你添加新算法你不能给GPU添加太多的负担。
![](20260207_135715_051.png)
如果让GPU去运行时访问一个提速数的话那就太耗费了。
相比之下手游的PCCPU倒是目前是经常什么八核心
4加4模式绑定一个比较节能的小核心用它去来异步的组装的这个3D纹理那还是比较合适的。
因此去异步组装一个包围相机的3D纹理然后GPU里只要去采样一下就可以了就立刻把这个UV电池给拿出来对吧
这不比烂的外部就是费多少。
你赖外部是采一个2D你现在采一个3D的而已然后手游同样是要离线的去分块存储数据但是每一个数据块内部你不能说就是这么稀疏的把它给存储了。
![](20260207_135715_052.png)
这个虽然省爆量但是你去访问起诉数的话那个CPU一定是有大量的cache miss它就非常的CPU不友好。
你也不能这样暴力的就把给它做一个规整的存储这样CCPU访问起来非常爽了但是它这个空间占用就会非常大就是非常的耗费包量。
大家别忘了说我们手游的一个很重要的要求就是节省包量。
![](20260207_135715_053.png)
这样我们就做一个折中就是每一个数据块内部就是分块存储每一个数据块内部把这个数据也做一个分段连续存储每个分段内部是一个规整的长方体网格相当于一个局部的小型3D纹理。
但它只包围自己的局部空间然后实时组装包围这个三相机的这个3D纹理的时候就这个绿色的大框它就是那个包围相机的3D纹理。
组装它的时候把每个数据块对齐到3D纹理中自己对应的那个位置上然后把这个数据给填充到3D纹理里面去。
当然这个3D纹理肯定是要做各种scoring然后追求一个稳定的显示然后分层分蒸的上传到GPU上去。
这里你需要注意的是你可以多利用手机上这种特有的这种半精度浮点数的这个SMD指令它它就可以极大的加速这个组装的过程。
然后大家这就到了整个GI方案里代码量最大的地方了就是这个手游的防漏光。
手游大家注意这个3D门里精度是1米那你墙是无论如何都不可能做到1米厚的。
所以说这个没有办法通过制作来解决,那怎么办呢?
这就要定义一种叫做iteration of volume的这个体积就是简称IV。
这个IV是随着建筑一起做的你可以认为是一个简化的麦氏就是大体表达出这个建筑的形状。
这个IV其实是一个多面体了它有多个面组成。
制作的时候要求一个建筑可以由多个IV拼起来每一个IV必须是突兀多面体。
比如说这个IV的这个房子的核心区域它是一个正方体那IV的每个面正好是那这个IV每个面正好是卡在墙里面的那比如这个面它代表了这堵墙我把它拉出来了看的明显一些。
所以说就可以在离线根据IV的面片和这个体素的位置识别出能够导致漏光的那些体素然后把这些体素与这个面片给关联起来。
那在运行时的时候就可以根据相机在这个面片的前与后这个关系来判断出出这些体素是不是在墙的另一边。
如果是的话就把这个体素的数值给fout掉。
我们看一下这个但是因为漏光的区域可能涉及到3D纹理里面各个区域而这个纹理你又不得不分针上传所以说你就不得不允许有一个滞后更新的这么一个现象。
其实在PVP对战的时候这个倒是没有人在意的这个实在是一个没有办法的事情。
这个防手游的防说起来简单其实它是一个巨大的代码量它可能是整个所有系统里代码量最大的地方但其实没有关系大家看我头发然后手游的这个数据压缩和端游是类似的只不过它这个pro不会稀疏很多。
咱们刚才不说了吗?
就是靠近麦氏的地方每个数据库就是靠近麦氏的地方有这个数据块cover远离mesh的地方那如果这个数据是什么呢
那就直接采用这个地方如果需要数据的话就直接采用距离操最近的这个pro上的光照。
所以说运行时还去动态生成了一个distant back field指向每一个位置距离它最近的那个pro OK。
咱们看一下重点关注的爆量volume加上这个报量最大这个图GI常规硒鼓达到了334兆。
那如果这个全都用lid map来做的这个PI的报量会翻倍五倍不止。
但是这么大的地图你全用LDMAB做那肯定也不太可能的事就是工期就不允许。
然后这里就稍微总结一下就是手机上的volume GI仍然是大幅的降低了制作的代价。
对比纯用来的mab相比这个包量也大幅下降都达这都达到了目的。
防漏光我们用了IV的方式也没有造成什么太多的拉扯。
就是这个太阳光的棒子相对于来的map相比确实是范围小了就眼前64米这么远。
但是手游上也是确实足够了总体性价比还是OK的没有问题。
OK然后我们看看一下这个远景的方案。
是远景的话我刚才说了就是远景就一个平均值替代如果再高配一点机器用一个纵向俯拍的2d text。
这里有一个非常重要的是我们大量的使用了SMD。
正好UE对SMD的这个封装也是非常完善的就是直接很直观的用这个函数名字你就不用再去写SMD的时候对应的一个一个那种鬼画符。
然后对,刚才这是多人的部分,咱们再看看单人战役的部分,这就是这个黑鹰坠落。
这个其实能说的并不多,就是黑鹰黑鹰坠落本身开发的时间非常短。
那作为罗那罗美作为不需要让人进行任何等待,这个实时也其实美术对它进行调节,能立即看到光照效果。
其实他那个反复调整的效率,它这个光照品质是更高的。
OK然后黑鹰的lemon没有做二次开发就是优于我的原生lemon。
那是用的是软光追没,没有开硬光追。
从试验的情况看就是3080级别以上显卡的时候用硬光追的这个路面性能才会超过这个软光追所以这里还是稍微照顾了一下显卡的性能OK。
然后咱们今天就做一下这个总结。
首先就是这个live map需要做取舍你需要满你只要能满足性能和这个效果的时候你可以更多的使用这个William GI去提升制作效率。
然后如果比如说你从0.25硬拉到0.1巷light my普遍这个文速精度是0.1。
你从0.25拉到0.1之后,其实这个边际效应是有点递减的。
所以说你可以多采用一下这个volunt来提升一下效率非常关键的就是你做好防漏光是volume GI跳出性价比陷阱的关键。
甚至你说你做volume GI的话你应该优先考虑防漏光然后再想后边那些其他算法OK然后PC上我们多关注一下显存的控制然后包边上要多注关注包量然后还有这个就是像罗曼能带来巨大的效率制作效率的提升。
我相信再过几年等玩家最低显卡都已经到3080级别的时候roman一定就是在市面上非常最普遍的一个方案。
OK这就是我今天的演讲谢谢大家我的车谢谢大家。

217
tools/tongyi/MD.py Normal file
View File

@@ -0,0 +1,217 @@
#!/usr/bin/env python
#coding=utf-8
import os
import json
import datetime
from PptExtraction import main as ppt_main
from Transcription import main as transcription_main
def get_ppt_result():
"""
运行 PptExtraction.py 并获取结果字典
"""
try:
# 导入 PptExtraction 模块的函数
from PptExtraction import read_a_txt, download_ppt_extraction, download_image
# 1. 读取并解析 a.txt 文件
ppt_extraction_url = read_a_txt()
if not ppt_extraction_url:
print("无法提取 PptExtraction 链接")
return {}
# 2. 下载并解析 PptExtraction 内容
key_frame_list = download_ppt_extraction(ppt_extraction_url)
if not key_frame_list:
print("无法获取 PPT 内容")
return {}
# 3. 生成时间戳年月日_时分秒
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
print(f"生成时间戳: {timestamp}")
# 4. 创建保存目录
save_dir = r'd:\Xuanchi\高斯泼溅\XCNote\tools\tongyi\ppt_output'
if not os.path.exists(save_dir):
os.makedirs(save_dir)
print(f"创建保存目录: {save_dir}")
# 5. 下载图片并整理结果
result_dict = {}
downloaded_count = 0
for i, frame in enumerate(key_frame_list):
image_url = frame.get('FileUrl')
if image_url:
# 下载图片
image_filename = download_image(image_url, save_dir, timestamp, i+1)
if image_filename:
downloaded_count += 1
# 整理成字典条目
result_dict[i+1] = {
"Start": frame.get('Start'),
"End": frame.get('End'),
"Type": "image",
"Content": f"![]({image_filename})"
}
print(f"成功获取 PPT 结果,共 {len(result_dict)} 张图片")
return result_dict
except Exception as e:
print(f"获取 PPT 结果失败: {e}")
import traceback
traceback.print_exc()
return {}
def get_transcription_result():
"""
运行 Transcription.py 并获取结果字典
"""
try:
# 导入 Transcription 模块的函数
from Transcription import read_a_txt, download_transcription, process_transcription
# 1. 读取并解析 a.txt 文件
transcription_url = read_a_txt()
if not transcription_url:
print("无法提取 Transcription 链接")
return {}
# 2. 下载并解析 Transcription 内容
paragraphs = download_transcription(transcription_url)
if not paragraphs:
print("无法获取 Transcription 内容")
return {}
# 3. 处理 Transcription 内容
result_dict = process_transcription(paragraphs)
# 转换格式,添加 Type 字段
for key, value in result_dict.items():
value["Type"] = "text"
value["Content"] = value.pop("Text")
print(f"成功获取 Transcription 结果,共 {len(result_dict)} 个句子")
return result_dict
except Exception as e:
print(f"获取 Transcription 结果失败: {e}")
import traceback
traceback.print_exc()
return {}
def merge_results(ppt_result, transcription_result):
"""
根据时间顺序拼合两个结果字典
"""
try:
# 转换为列表以便排序
items = []
# 添加 PPT 项目
for key, value in ppt_result.items():
items.append({
"id": f"ppt_{key}",
"start": value["Start"],
"end": value["End"],
"type": value["Type"],
"content": value["Content"]
})
# 添加 Transcription 项目
for key, value in transcription_result.items():
items.append({
"id": f"trans_{key}",
"start": value["Start"],
"end": value["End"],
"type": value["Type"],
"content": value["Content"]
})
# 根据 start 时间排序,相同时间下图片优先
items.sort(key=lambda x: (x["start"], 0 if x["type"] == "image" else 1))
print(f"成功拼合结果,共 {len(items)} 个项目")
return items
except Exception as e:
print(f"拼合结果失败: {e}")
import traceback
traceback.print_exc()
return []
def generate_md(items):
"""
根据拼合结果生成 md 文档
"""
try:
# 生成时间戳
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
# 生成 md 文件名
md_filename = f"{timestamp}_merged.md"
md_path = os.path.join(r'd:\Xuanchi\高斯泼溅\XCNote\tools\tongyi', md_filename)
# 创建 md 内容
md_content = f"# 拼合内容\n\n"
md_content += f"生成时间: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
# 添加拼合的内容
for item in items:
if item["type"] == "image":
md_content += f"{item['content']}\n\n"
else:
md_content += f"{item['content']}\n\n"
# 保存 md 文件
with open(md_path, 'w', encoding='utf-8') as f:
f.write(md_content)
print(f"成功生成 md 文档: {md_filename}")
return md_filename
except Exception as e:
print(f"生成 md 文档失败: {e}")
import traceback
traceback.print_exc()
return None
def main():
"""
主函数
"""
print("===== 开始生成拼合 MD 文档 =====")
# 1. 获取 PPT 结果
print("\n===== 获取 PPT 结果 =====")
ppt_result = get_ppt_result()
# 2. 获取 Transcription 结果
print("\n===== 获取 Transcription 结果 =====")
transcription_result = get_transcription_result()
# 3. 拼合结果
print("\n===== 拼合结果 =====")
merged_items = merge_results(ppt_result, transcription_result)
if not merged_items:
print("无法生成拼合结果,程序退出")
return
# 4. 生成 md 文档
print("\n===== 生成 MD 文档 =====")
md_filename = generate_md(merged_items)
if md_filename:
print(f"\n===== 处理完成 =====")
print(f"生成的 MD 文档: {md_filename}")
print(f"保存位置: d:\Xuanchi\高斯泼溅\XCNote\tools\tongyi\{md_filename}")
else:
print("生成 MD 文档失败,程序退出")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,146 @@
#!/usr/bin/env python
#coding=utf-8
import os
import json
import requests
import datetime
from urllib.parse import urlparse
def read_a_txt():
"""
读取并解析 a.txt 文件,提取 PptExtraction 链接
"""
try:
file_path = r'd:\Xuanchi\高斯泼溅\XCNote\tools\tongyi\a.txt'
with open(file_path, 'r', encoding='utf-8') as f:
# 读取文件内容,跳过开头的 "响应数据: " 字符串
content = f.read().replace('响应数据: ', '')
data = json.loads(content)
ppt_extraction_url = data.get('Data', {}).get('Result', {}).get('PptExtraction')
if ppt_extraction_url:
print(f"成功提取 PptExtraction 链接: {ppt_extraction_url}")
return ppt_extraction_url
else:
print("无法找到 PptExtraction 链接")
return None
except Exception as e:
print(f"读取 a.txt 文件失败: {e}")
import traceback
traceback.print_exc()
return None
def download_ppt_extraction(url):
"""
下载并解析 PptExtraction 内容
"""
try:
print(f"开始下载 PptExtraction 内容: {url}")
response = requests.get(url, timeout=30)
response.raise_for_status()
data = response.json()
ppt_extraction_data = data.get('PptExtraction', {})
key_frame_list = ppt_extraction_data.get('KeyFrameList', [])
print(f"成功下载并解析 PptExtraction 内容,共 {len(key_frame_list)} 张图片")
return key_frame_list
except Exception as e:
print(f"下载 PptExtraction 内容失败: {e}")
import traceback
traceback.print_exc()
return []
def download_image(url, save_dir, timestamp, index):
"""
下载图片并保存到指定目录
"""
try:
print(f"开始下载图片: {url}")
response = requests.get(url, timeout=30)
response.raise_for_status()
# 生成图片文件名年月日_时分秒_序号.png
image_filename = f"{timestamp}_{str(index).zfill(3)}.png"
image_path = os.path.join(save_dir, image_filename)
# 保存图片
with open(image_path, 'wb') as f:
f.write(response.content)
print(f"成功下载图片: {image_filename}")
return image_filename
except Exception as e:
print(f"下载图片失败: {e}")
import traceback
traceback.print_exc()
return None
def main():
"""
主函数
"""
print("===== 开始处理 PPT 提取结果 =====")
# 1. 读取并解析 a.txt 文件
ppt_extraction_url = read_a_txt()
if not ppt_extraction_url:
print("无法提取 PptExtraction 链接,程序退出")
return
# 2. 下载并解析 PptExtraction 内容
key_frame_list = download_ppt_extraction(ppt_extraction_url)
if not key_frame_list:
print("无法获取 PPT 内容,程序退出")
return
# 3. 生成时间戳年月日_时分秒
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
print(f"生成时间戳: {timestamp}")
# 4. 创建保存目录
save_dir = r'd:\Xuanchi\高斯泼溅\XCNote\tools\tongyi\ppt_output'
if not os.path.exists(save_dir):
os.makedirs(save_dir)
print(f"创建保存目录: {save_dir}")
# 5. 下载图片并整理结果
result_dict = {}
downloaded_count = 0
for i, frame in enumerate(key_frame_list):
image_url = frame.get('FileUrl')
if image_url:
# 下载图片
image_filename = download_image(image_url, save_dir, timestamp, i+1)
if image_filename:
downloaded_count += 1
# 整理结果到字典
result_dict[i+1] = {
"Start": frame.get('Start'),
"End": frame.get('End'),
"ImageMd": f"![]({image_filename})"
}
if not downloaded_count:
print("无法下载任何图片,程序退出")
return
# 6. 输出整理结果
print(f"\n===== 处理完成 =====")
print(f"保存目录: {save_dir}")
print(f"下载的图片数量: {downloaded_count}")
print(f"\n整理结果:")
print(json.dumps(result_dict, indent=4, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,135 @@
#!/usr/bin/env python
#coding=utf-8
import os
import json
import requests
import datetime
def read_a_txt():
"""
读取并解析 a.txt 文件,提取 Transcription 链接
"""
try:
file_path = r'd:\Xuanchi\高斯泼溅\XCNote\tools\tongyi\a.txt'
with open(file_path, 'r', encoding='utf-8') as f:
# 读取文件内容,跳过开头的 "响应数据: " 字符串
content = f.read().replace('响应数据: ', '')
data = json.loads(content)
transcription_url = data.get('Data', {}).get('Result', {}).get('Transcription')
if transcription_url:
print(f"成功提取 Transcription 链接: {transcription_url}")
return transcription_url
else:
print("无法找到 Transcription 链接")
return None
except Exception as e:
print(f"读取 a.txt 文件失败: {e}")
import traceback
traceback.print_exc()
return None
def download_transcription(url):
"""
下载并解析 Transcription 内容
"""
try:
print(f"开始下载 Transcription 内容: {url}")
response = requests.get(url, timeout=30)
response.raise_for_status()
data = response.json()
transcription_data = data.get('Transcription', {})
paragraphs = transcription_data.get('Paragraphs', [])
print(f"成功下载并解析 Transcription 内容,共 {len(paragraphs)} 个段落")
return paragraphs
except Exception as e:
print(f"下载 Transcription 内容失败: {e}")
import traceback
traceback.print_exc()
return []
def process_transcription(paragraphs):
"""
处理 Transcription 内容,根据 SentenceId 拼合成句子,并整理成字典
"""
try:
result_dict = {}
sentence_counter = 1
for paragraph in paragraphs:
words = paragraph.get('Words', [])
# 按 SentenceId 分组
sentences = {}
for word in words:
sentence_id = word.get('SentenceId')
if sentence_id not in sentences:
sentences[sentence_id] = {
'words': [word.get('Text')],
'start': word.get('Start'),
'end': word.get('End')
}
else:
sentences[sentence_id]['words'].append(word.get('Text'))
# 更新结束时间
sentences[sentence_id]['end'] = word.get('End')
# 处理每个句子
for sentence_id, sentence_data in sorted(sentences.items()):
# 拼接句子
sentence_text = ''.join(sentence_data['words'])
if sentence_text:
# 整理成字典条目
result_dict[sentence_counter] = {
"Start": sentence_data['start'],
"End": sentence_data['end'],
"Text": sentence_text
}
sentence_counter += 1
print(f"成功处理 Transcription 内容,共 {len(result_dict)} 个句子")
return result_dict
except Exception as e:
print(f"处理 Transcription 内容失败: {e}")
import traceback
traceback.print_exc()
return {}
def main():
"""
主函数
"""
print("===== 开始处理 Transcription 结果 =====")
# 1. 读取并解析 a.txt 文件
transcription_url = read_a_txt()
if not transcription_url:
print("无法提取 Transcription 链接,程序退出")
return
# 2. 下载并解析 Transcription 内容
paragraphs = download_transcription(transcription_url)
if not paragraphs:
print("无法获取 Transcription 内容,程序退出")
return
# 3. 处理 Transcription 内容
result_dict = process_transcription(paragraphs)
if not result_dict:
print("处理 Transcription 内容失败,程序退出")
return
# 4. 输出整理结果
print(f"\n===== 处理完成 =====")
print(f"整理结果: ")
print(json.dumps(result_dict, indent=4, ensure_ascii=False))
if __name__ == "__main__":
main()

21
tools/tongyi/a.txt Normal file
View File

@@ -0,0 +1,21 @@
响应数据: {
"Code": "0",
"Data": {
"TaskId": "81e04145c55941e1b919de400462326a",
"TaskKey": "task20260207125324",
"TaskStatus": "COMPLETED",
"OutputMp3Path": "https://prod-tingwu-paas-common-beijing.oss-cn-beijing.aliyuncs.com/tingwu/output/1805511367110736/81e04145c55941e1b919de400462326a/81e04145c55941e1b919de400462326a_20260207125325.mp3?Expires=1773032413&OSSAccessKeyId=LTAI5tMzZ1D4o1drkJN1TfCr&Signature=bLEZ7Bzwe5W6mJitvpJQxC19G%2B4%3D",
"Result": {
"MeetingAssistance": "https://prod-tingwu-paas-common-beijing.oss-cn-beijing.aliyuncs.com/tingwu/output/1805511367110736/81e04145c55941e1b919de400462326a/81e04145c55941e1b919de400462326a_MeetingAssistance_20260207125445.json?Expires=1773032413&OSSAccessKeyId=LTAI5tMzZ1D4o1drkJN1TfCr&Signature=SJxdBQpKfNmFbkuazqG1AUFW68g%3D",
"PptExtraction": "https://prod-tingwu-paas-common-beijing.oss-cn-beijing.aliyuncs.com/tingwu/output/1805511367110736/81e04145c55941e1b919de400462326a/81e04145c55941e1b919de400462326a_PptExtraction_valid_20260207130013.json?Expires=1773032413&OSSAccessKeyId=LTAI5tMzZ1D4o1drkJN1TfCr&Signature=RRL08gHn%2BV2plMmRXUb7lyMfQds%3D",
"Translation": "https://prod-tingwu-paas-common-beijing.oss-cn-beijing.aliyuncs.com/tingwu/output/1805511367110736/81e04145c55941e1b919de400462326a/81e04145c55941e1b919de400462326a_Translation_20260207125405.json?Expires=1773032413&OSSAccessKeyId=LTAI5tMzZ1D4o1drkJN1TfCr&Signature=upttZKNYQZPqtssroLXGxl0SYcg%3D",
"Transcription": "https://prod-tingwu-paas-common-beijing.oss-cn-beijing.aliyuncs.com/tingwu/output/1805511367110736/81e04145c55941e1b919de400462326a/81e04145c55941e1b919de400462326a_Transcription_20260207125355.json?Expires=1773032413&OSSAccessKeyId=LTAI5tMzZ1D4o1drkJN1TfCr&Signature=5PTjx229YKQBEa3jc6LRpAqqHuY%3D",
"AutoChapters": "https://prod-tingwu-paas-common-beijing.oss-cn-beijing.aliyuncs.com/tingwu/output/1805511367110736/81e04145c55941e1b919de400462326a/81e04145c55941e1b919de400462326a_AutoChapters_20260207125605.json?Expires=1773032413&OSSAccessKeyId=LTAI5tMzZ1D4o1drkJN1TfCr&Signature=CBWZBlrJ4LVMaq82Xsn8X6G%2Faag%3D",
"TextPolish": "https://prod-tingwu-paas-common-beijing.oss-cn-beijing.aliyuncs.com/tingwu/output/1805511367110736/81e04145c55941e1b919de400462326a/81e04145c55941e1b919de400462326a_TextPolish_20260207130006.json?Expires=1773032413&OSSAccessKeyId=LTAI5tMzZ1D4o1drkJN1TfCr&Signature=kaoVSkcauKQCr8PydJec8WcMZqs%3D",
"Transcoding": "https://prod-tingwu-paas-common-beijing.oss-cn-beijing.aliyuncs.com/tingwu/output/1805511367110736/81e04145c55941e1b919de400462326a/81e04145c55941e1b919de400462326a_Transcoding_valid_20260207130013.json?Expires=1773032413&OSSAccessKeyId=LTAI5tMzZ1D4o1drkJN1TfCr&Signature=CgbAuV7FRY16EKzTUf6Vwm9D3Iw%3D",
"Summarization": "https://prod-tingwu-paas-common-beijing.oss-cn-beijing.aliyuncs.com/tingwu/output/1805511367110736/81e04145c55941e1b919de400462326a/81e04145c55941e1b919de400462326a_Summarization_20260207125455.json?Expires=1773032413&OSSAccessKeyId=LTAI5tMzZ1D4o1drkJN1TfCr&Signature=OmS5ZCzIxPEE012YmywRdS4qYmQ%3D"
}
},
"Message": "success",
"RequestId": "33E89E9F-5FF9-57B1-9523-4336DD9B7A63"
}

View File

@@ -0,0 +1,93 @@
import os
import sys
from utils import (
extract_bv_from_url,
get_video_info,
get_play_urls,
download_audio_video,
merge_audio_video,
cleanup_files,
sanitize_filename
)
from config import DOWNLOAD_DIR
def crawl_bilibili_video(video_url, download=False):
"""
爬取B站视频
:param video_url: 视频分享链接
:param download: 是否下载视频默认False
:return: 视频标题和视频播放URL
"""
print(f"开始爬取视频: {video_url}")
try:
# 1. 提取BV号
bv_id = extract_bv_from_url(video_url)
print(f"提取到BV号: {bv_id}")
# 2. 获取视频信息
video_info = get_video_info(bv_id)
video_title = video_info.get("title", f"{bv_id}")
cid = video_info.get("cid")
print(f"视频标题: {video_title}")
print(f"视频CID: {cid}")
# 3. 获取播放链接
play_urls = get_play_urls(bv_id, cid)
# 选择最高质量的视频和音频
if not play_urls.get("video") or not play_urls.get("audio"):
raise Exception("无法获取音视频流链接")
# 选择最高质量的视频
video_item = sorted(play_urls["video"], key=lambda x: x["quality"], reverse=True)[0]
video_play_url = video_item["url"]
print(f"选择视频质量: {video_item['quality']}")
print(f"视频播放URL: {video_play_url}")
# 选择最高质量的音频
audio_item = sorted(play_urls["audio"], key=lambda x: x["quality"], reverse=True)[0]
audio_url = audio_item["url"]
print(f"选择音频质量: {audio_item['quality']}")
# 4. 下载音视频(可选)
if download:
print("开始下载音视频...")
save_dir = os.path.join(DOWNLOAD_DIR, sanitize_filename(video_title))
video_path, audio_path = download_audio_video(video_play_url, audio_url, save_dir, video_title)
# 5. 合并音视频
output_filename = f"{sanitize_filename(video_title)}.mp4"
output_path = os.path.join(DOWNLOAD_DIR, output_filename)
merge_audio_video(video_path, audio_path, output_path)
# 6. 清理临时文件
cleanup_files(video_path, audio_path)
print(f"视频爬取完成,保存路径: {output_path}")
else:
print("跳过下载直接使用播放URL")
# 返回视频标题和视频播放URL
return video_title, video_play_url
except Exception as e:
print(f"爬取失败: {e}")
import traceback
traceback.print_exc()
return None, None
def main():
"""
主函数
"""
if len(sys.argv) != 2:
print("使用方法: python bilibili_spider.py <视频链接>")
print("示例: python bilibili_spider.py https://www.bilibili.com/video/BV1uHFjzVEju/")
sys.exit(1)
video_url = sys.argv[1]
crawl_bilibili_video(video_url, download=True) # 直接运行时默认下载视频
if __name__ == "__main__":
main()

14
tools/tongyi/config.py Normal file
View File

@@ -0,0 +1,14 @@
# 配置文件
# User-Agent设置
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
# Cookie设置可选如需登录后才能访问的视频
COOKIE = ""
# 下载配置
DOWNLOAD_DIR = "./downloads"
CHUNK_SIZE = 1024 * 1024 # 1MB
# API配置
API_URL = "https://api.bilibili.com/x/player/playurl"

140
tools/tongyi/main.py Normal file
View File

@@ -0,0 +1,140 @@
#!/usr/bin/env python
# coding=utf-8
import sys
import os
from bilibili_spider import crawl_bilibili_video
from tingwu_api import submit_tingwu_task, get_task_result, check_task_status
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from shared.oss_upload import upload_file_to_oss
def main():
"""
主函数爬取B站视频并提交给通义听悟API分析
"""
if len(sys.argv) != 2:
print("使用方法: python bilibili_to_tingwu.py <视频链接>")
print(
"示例: python bilibili_to_tingwu.py https://www.bilibili.com/video/BV1uHFjzVEju/"
)
sys.exit(1)
video_url = sys.argv[1]
print("===== 开始爬取B站视频 =====")
# 爬取视频获取视频标题和播放URL下载视频
video_title, video_play_url = crawl_bilibili_video(video_url, download=True)
if not video_play_url:
print("视频爬取失败无法提交给通义听悟API")
sys.exit(1)
# 构建下载的文件路径
from config import DOWNLOAD_DIR
from utils import sanitize_filename
# 音频流路径
audio_stream_path = os.path.join(
DOWNLOAD_DIR,
sanitize_filename(video_title),
f"{sanitize_filename(video_title)}_audio.mp4",
)
# 视频流路径
video_stream_path = os.path.join(
DOWNLOAD_DIR,
sanitize_filename(video_title),
f"{sanitize_filename(video_title)}_video.mp4",
)
# 合并后的视频路径
merged_video_path = os.path.join(
DOWNLOAD_DIR, f"{sanitize_filename(video_title)}.mp4"
)
# 强制使用合并后的视频文件
if os.path.exists(merged_video_path):
upload_path = merged_video_path
print("使用合并后的视频文件进行分析")
else:
print("错误:无法找到合并后的视频文件")
print("必须成功合并音视频才能继续")
sys.exit(1)
print(f"上传文件路径: {upload_path}")
print("\n===== 上传视频到OSS =====")
# 配置OSS参数
ACCESS_KEY_ID = "LTAI5tB7sQADpKZnXY7s6Xz8"
ACCESS_KEY_SECRET = "Fgab9klwKoH1GACP97WIb7s6BSvNAm"
BUCKET_NAME = "bucket-xcnote" # 请替换为实际的OSS桶名称
# 生成OSS对象名称
import datetime
timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
object_name = f"bilibili/{timestamp}_{sanitize_filename(video_title)}.mp4"
# 上传文件到OSS
oss_url = upload_file_to_oss(
upload_path, BUCKET_NAME, object_name, ACCESS_KEY_ID, ACCESS_KEY_SECRET
)
if not oss_url:
print("文件上传失败无法提交给通义听悟API")
sys.exit(1)
print("\n===== 开始提交给通义听悟API分析 =====")
print(f"视频标题: {video_title}")
print(f"使用的OSS文件URL: {oss_url}")
# 配置参数
APP_KEY = "vjZPEUfWtszQP2n6" # 必须替换为实际的AppKey
# 使用OSS的文件URL作为FileUrl
file_url = oss_url
# 提交任务
task_id = submit_tingwu_task(file_url, APP_KEY, ACCESS_KEY_ID, ACCESS_KEY_SECRET)
if task_id:
print("\n===== 等待任务处理完成 =====")
# 轮询任务状态,直到任务完成或失败
import time
max_retries = 60 # 最大轮询次数
retry_interval = 10 # 轮询间隔(秒)
retry_count = 0
while retry_count < max_retries:
print(f"\n{retry_count + 1} 次查询任务状态...")
task_status = check_task_status(task_id, ACCESS_KEY_ID, ACCESS_KEY_SECRET)
if task_status == "COMPLETED":
print("\n===== 任务处理完成 =====")
print("分析完成,结果已打印如上")
break
elif task_status in ["FAILED", "INVALID"]:
print(f"\n===== 任务处理{task_status} =====")
print("分析完成,结果已打印如上")
break
elif task_status == "ONGOING":
print(f"任务正在处理中,{retry_interval}秒后再次查询...")
time.sleep(retry_interval)
retry_count += 1
else:
print(f"未知任务状态: {task_status}{retry_interval}秒后再次查询...")
time.sleep(retry_interval)
retry_count += 1
if retry_count >= max_retries:
print("\n===== 任务处理超时 =====")
print("已达到最大查询次数,任务可能仍在处理中")
else:
print("\n===== 提交任务失败 =====")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,3 @@
requests
beautifulsoup4
tqdm

176
tools/tongyi/tingwu_api.py Normal file
View File

@@ -0,0 +1,176 @@
#!/usr/bin/env python
#coding=utf-8
import os
import json
import datetime
from aliyunsdkcore.client import AcsClient
from aliyunsdkcore.request import CommonRequest
from aliyunsdkcore.auth.credentials import AccessKeyCredential
def create_common_request(domain, version, protocolType, method, uri):
"""
创建通用请求对象
"""
request = CommonRequest()
request.set_accept_format('json')
request.set_domain(domain)
request.set_version(version)
request.set_protocol_type(protocolType)
request.set_method(method)
request.set_uri_pattern(uri)
request.add_header('Content-Type', 'application/json')
return request
def init_parameters(app_key, file_url):
"""
初始化请求参数
"""
body = dict()
body['AppKey'] = app_key
# 基本请求参数
input = dict()
input['SourceLanguage'] = 'cn'
input['TaskKey'] = 'task' + datetime.datetime.now().strftime('%Y%m%d%H%M%S')
input['FileUrl'] = file_url
body['Input'] = input
# AI相关参数按需设置即可
parameters = dict()
# 音视频转换相关
transcoding = dict()
# 将原音视频文件转成mp3文件用以后续浏览器播放
transcoding['TargetAudioFormat'] = 'mp3'
transcoding['SpectrumEnabled'] = False
parameters['Transcoding'] = transcoding
# 语音识别控制相关
transcription = dict()
# 角色分离 可选
transcription['DiarizationEnabled'] = True
diarization = dict()
diarization['SpeakerCount'] = 2
transcription['Diarization'] = diarization
parameters['Transcription'] = transcription
# 文本翻译控制相关 可选
parameters['TranslationEnabled'] = True
translation = dict()
translation['TargetLanguages'] = ['en'] # 假设翻译成英文
parameters['Translation'] = translation
# 章节速览相关 可选,包括: 标题、议程摘要
parameters['AutoChaptersEnabled'] = True
# 智能纪要相关 可选,包括: 待办、关键信息(关键词、重点内容、场景识别)
parameters['MeetingAssistanceEnabled'] = True
meetingAssistance = dict()
meetingAssistance['Types'] = ['Actions', 'KeyInformation']
parameters['MeetingAssistance'] = meetingAssistance
# 摘要控制相关 可选,包括: 全文摘要、发言人总结摘要、问答摘要(问答回顾)
parameters['SummarizationEnabled'] = True
summarization = dict()
summarization['Types'] = ['Paragraph', 'Conversational', 'QuestionsAnswering', 'MindMap']
parameters['Summarization'] = summarization
# ppt抽取和ppt总结 可选
parameters['PptExtractionEnabled'] = True
# 口语书面化 可选
parameters['TextPolishEnabled'] = True
# 大模型后处理任务全局参数 可选
parameters['Model'] = 'qwq'
parameters['LlmOutputLanguage'] = 'en'
body['Parameters'] = parameters
return body
def submit_tingwu_task(file_url, app_key, access_key_id, access_key_secret):
"""
提交通义听悟任务
"""
print(f"开始提交通义听悟任务文件URL: {file_url}")
try:
# 初始化参数
body = init_parameters(app_key, file_url)
print("初始化参数完成")
# 初始化客户端
credentials = AccessKeyCredential(access_key_id, access_key_secret)
client = AcsClient(region_id='cn-beijing', credential=credentials)
print("初始化客户端完成")
# 创建请求
request = create_common_request('tingwu.cn-beijing.aliyuncs.com', '2023-09-30', 'https', 'PUT', '/openapi/tingwu/v2/tasks')
request.add_query_param('type', 'offline')
# 设置请求内容
request.set_content(json.dumps(body).encode('utf-8'))
# 发送请求
response = client.do_action_with_exception(request)
response_data = json.loads(response)
print("任务提交成功")
print(f"响应数据: {json.dumps(response_data, indent=4, ensure_ascii=False)}")
# 返回任务ID
task_id = response_data.get('Data', {}).get('TaskId')
if task_id:
print(f"获取到任务ID: {task_id}")
return task_id
else:
print("无法获取任务ID")
return None
except Exception as e:
print(f"任务提交失败: {e}")
import traceback
traceback.print_exc()
return None
def get_task_result(task_id, access_key_id, access_key_secret):
"""
获取任务结果
"""
print(f"开始查询任务结果任务ID: {task_id}")
try:
# 初始化客户端
credentials = AccessKeyCredential(access_key_id, access_key_secret)
client = AcsClient(region_id='cn-beijing', credential=credentials)
# 创建请求
uri = '/openapi/tingwu/v2/tasks' + '/' + task_id
request = create_common_request('tingwu.cn-beijing.aliyuncs.com', '2023-09-30', 'https', 'GET', uri)
# 发送请求
response = client.do_action_with_exception(request)
response_data = json.loads(response)
print("任务查询成功")
print(f"响应数据: {json.dumps(response_data, indent=4, ensure_ascii=False)}")
return response_data
except Exception as e:
print(f"任务查询失败: {e}")
import traceback
traceback.print_exc()
return None
def check_task_status(task_id, access_key_id, access_key_secret):
"""
检查任务状态
"""
result = get_task_result(task_id, access_key_id, access_key_secret)
if result:
task_status = result.get('Data', {}).get('TaskStatus')
print(f"当前任务状态: {task_status}")
return task_status
return None

254
tools/tongyi/utils.py Normal file
View File

@@ -0,0 +1,254 @@
# 工具函数
import os
import re
import requests
from tqdm import tqdm
from config import USER_AGENT, COOKIE, CHUNK_SIZE, DOWNLOAD_DIR
def extract_bv_from_url(url):
"""
从分享链接中提取BV号
:param url: 视频分享链接
:return: BV号
"""
pattern = r"BV[0-9A-Za-z]+"
match = re.search(pattern, url)
if match:
return match.group(0)
else:
raise ValueError("无法从链接中提取BV号")
def get_video_info(bv_id):
"""
获取视频信息
:param bv_id: BV号
:return: 视频信息字典
"""
url = f"https://api.bilibili.com/x/web-interface/view?bvid={bv_id}"
headers = {
"User-Agent": USER_AGENT,
"Cookie": COOKIE
}
response = requests.get(url, headers=headers)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取视频信息失败: {data.get('message')}")
return data.get("data", {})
def get_play_urls(bv_id, cid):
"""
获取视频播放链接
:param bv_id: BV号
:param cid: 视频cid
:return: 包含视频和音频链接的字典
"""
from config import API_URL
params = {
"bvid": bv_id,
"cid": cid,
"qn": 80,
"type": "",
"otype": "json",
"fourk": 1,
"fnval": 16
}
headers = {
"User-Agent": USER_AGENT,
"Cookie": COOKIE,
"Referer": "https://www.bilibili.com/",
"Origin": "https://www.bilibili.com"
}
response = requests.get(API_URL, params=params, headers=headers)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
raise Exception(f"获取播放链接失败: {data.get('message')}")
play_urls = {
"video": [],
"audio": []
}
video_info = data.get("data", {}).get("dash", {}).get("video", [])
audio_info = data.get("data", {}).get("dash", {}).get("audio", [])
if not video_info:
durl = data.get("data", {}).get("durl", [])
for item in durl:
play_urls["video"].append({
"url": item.get("url"),
"quality": 80,
"codec": "h264"
})
else:
for item in video_info:
play_urls["video"].append({
"url": item.get("baseUrl"),
"quality": item.get("id"),
"codec": item.get("codecs")
})
if not audio_info and video_info:
for item in video_info:
play_urls["audio"].append({
"url": item.get("baseUrl"),
"quality": item.get("id"),
"codec": item.get("codecs")
})
else:
for item in audio_info:
play_urls["audio"].append({
"url": item.get("baseUrl"),
"quality": item.get("id"),
"codec": item.get("codecs")
})
print(f"获取到视频流数量: {len(play_urls['video'])}")
print(f"获取到音频流数量: {len(play_urls['audio'])}")
return play_urls
def sanitize_filename(filename):
"""
清理文件名,移除非法字符
:param filename: 原始文件名
:return: 清理后的文件名
"""
illegal_chars = r"[<>:/\\|?*]"
return re.sub(illegal_chars, "_", filename)
def download_file(url, save_path):
"""
下载文件
:param url: 文件下载链接
:param save_path: 保存路径
:return: 保存路径
"""
os.makedirs(os.path.dirname(save_path), exist_ok=True)
headers = {
"User-Agent": USER_AGENT,
"Cookie": COOKIE,
"Referer": "https://www.bilibili.com/"
}
response = requests.get(url, headers=headers, stream=True)
response.raise_for_status()
total_size = int(response.headers.get("content-length", 0))
with open(save_path, "wb") as file, tqdm(
desc=os.path.basename(save_path),
total=total_size,
unit="iB",
unit_scale=True,
unit_divisor=1024,
) as bar:
for chunk in response.iter_content(chunk_size=CHUNK_SIZE):
size = file.write(chunk)
bar.update(size)
return save_path
def download_audio_video(video_url, audio_url, save_dir, video_title):
"""
下载视频和音频
:param video_url: 视频流链接
:param audio_url: 音频流链接
:param save_dir: 保存目录
:param video_title: 视频标题
:return: 视频和音频保存路径
"""
sanitized_title = sanitize_filename(video_title)
video_path = os.path.join(save_dir, f"{sanitized_title}_video.mp4")
audio_path = os.path.join(save_dir, f"{sanitized_title}_audio.mp4")
print(f"正在下载视频: {video_title}")
download_file(video_url, video_path)
print(f"正在下载音频: {video_title}")
download_file(audio_url, audio_path)
return video_path, audio_path
def merge_audio_video(video_path, audio_path, output_path):
"""
合并音视频
:param video_path: 视频文件路径
:param audio_path: 音频文件路径
:param output_path: 输出文件路径
:return: 输出文件路径
"""
import subprocess
os.makedirs(os.path.dirname(output_path), exist_ok=True)
print(f"正在合并音视频: {os.path.basename(output_path)}")
# 尝试使用FFmpeg合并音视频
cmd = [
"ffmpeg",
"-i", video_path,
"-i", audio_path,
"-c:v", "copy",
"-c:a", "aac",
"-strict", "experimental",
"-y",
output_path
]
try:
# 尝试执行FFmpeg命令使用字节模式避免编码问题
result = subprocess.run(
cmd,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
print("音视频合并完成")
except subprocess.CalledProcessError as e:
# FFmpeg失败尝试使用其他方法
print(f"FFmpeg合并失败: {e.stderr}")
print("尝试使用备用方法...")
# 备用方法使用Python的shutil复制文件
# 这里只是一个占位符,实际需要更复杂的实现
# 但至少可以让用户知道问题所在
raise Exception(
"音视频合并失败请确保已安装FFmpeg并添加到系统路径\n"
"或尝试安装MoviePy: pip install moviepy"
)
except FileNotFoundError:
raise Exception(
"FFmpeg未找到请安装FFmpeg并添加到系统路径\n"
"下载地址: https://ffmpeg.org/download.html"
)
return output_path
def cleanup_files(*file_paths):
"""
清理临时文件
:param file_paths: 要清理的文件路径
"""
for file_path in file_paths:
if os.path.exists(file_path):
try:
os.remove(file_path)
print(f"已清理临时文件: {os.path.basename(file_path)}")
except Exception as e:
print(f"清理临时文件失败: {e}")